diff --git a/css/dashboard.css b/css/dashboard.css index 32da7c6..5d40e30 100644 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -433,8 +433,9 @@ input[type=range]::-moz-range-thumb { .icon.battery_10:before { content: "\F007A"; } .icon.bell:before { content: "\F009A"; } .icon.bell_off:before { content: "\F009B"; } -.icon.border_none_variant:before { content: "\F08A4"; } -.icon.bullhorn:before { content: "\F00E6"; } +.icon.border_none_variant:before { content: "\F08A4"; } +.icon.texture_box:before { content: "\F0FE6"; } +.icon.bullhorn:before { content: "\F00E6"; } .icon.calendar:before { content: "\F00ED"; } .icon.calendar_alert:before { content: "\F0A31"; } .icon.calendar_edit:before { content: "\F08A7"; } diff --git a/js/component/card/floor_plan_editor.js b/js/component/card/floor_plan_editor.js index d5a0f6f..e8c2db1 100644 --- a/js/component/card/floor_plan_editor.js +++ b/js/component/card/floor_plan_editor.js @@ -176,6 +176,7 @@ beestat.component.card.floor_plan_editor.prototype.decorate_contents_ = function */ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = function(parent) { const self = this; + const has_early_access = beestat.user.has_early_access() === true; // Dispose existing SVG to remove any global listeners. if (this.floor_plan_ !== undefined) { @@ -195,6 +196,11 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func this.floor_plan_.render(parent); + if (has_early_access !== true) { + delete this.state_.active_surface_entity; + delete this.state_.active_tree_entity; + } + // Create and render the compass for setting orientation (early access only) if (beestat.user.has_early_access() === true) { this.compass_ = new beestat.component.compass( @@ -343,6 +349,7 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func let active_surface_entity; this.state_.active_group.surfaces.forEach(function(surface) { const surface_entity = new beestat.component.floor_plan_entity.surface(self.floor_plan_, self.state_) + .set_enabled(has_early_access) .set_surface(surface) .set_group(self.state_.active_group); @@ -396,6 +403,7 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func let active_tree_entity; tree_group.trees.forEach(function(tree) { const tree_entity = new beestat.component.floor_plan_entity.tree(self.floor_plan_, self.state_) + .set_enabled(has_early_access) .set_tree(tree) .set_group(tree_group); @@ -602,8 +610,9 @@ beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_tree_ = fu const grid = $.createElement('div') .style({ 'display': 'grid', - 'grid-template-columns': 'repeat(auto-fit, minmax(150px, 1fr))', - 'column-gap': beestat.style.size.gutter + 'grid-template-columns': 'repeat(4, minmax(150px, 1fr))', + 'column-gap': beestat.style.size.gutter, + 'width': '100%' }); parent.appendChild(grid); @@ -694,8 +703,9 @@ beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_surface_ = const grid = $.createElement('div') .style({ 'display': 'grid', - 'grid-template-columns': 'repeat(auto-fit, minmax(150px, 1fr))', - 'column-gap': beestat.style.size.gutter + 'grid-template-columns': 'repeat(4, minmax(150px, 1fr))', + 'column-gap': beestat.style.size.gutter, + 'width': '100%' }); parent.appendChild(grid); @@ -708,29 +718,116 @@ beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_surface_ = .set_label('Color') .set_width('100%'); - const surface_colors = [ - {'label': 'Concrete', 'value': '#9e9e9e'}, - {'label': 'Asphalt', 'value': '#2f2f2f'}, - {'label': 'Mulch - Brown', 'value': '#6f4e37'}, - {'label': 'Mulch - Black', 'value': '#1f1b1a'}, - {'label': 'Gravel', 'value': '#b3aea3'}, - {'label': 'Pavers', 'value': '#8c6d5a'}, - {'label': 'Deck - Wood', 'value': '#8b5a2b'}, - {'label': 'Grass', 'value': '#4a7c3f'} - ]; + const normalize_hex_color = function(value) { + if (value === undefined || value === null) { + return undefined; + } + let normalized = String(value).trim(); + if (normalized === '') { + return undefined; + } + + if (normalized.charAt(0) !== '#') { + normalized = '#' + normalized; + } + + if (/^#[0-9a-fA-F]{6}$/.test(normalized) !== true) { + return undefined; + } + + return normalized.toLowerCase(); + }; + + const apply_surface_color = function(color) { + surface.color = color; + self.floor_plan_.update_infobox(); + self.update_floor_plan_(); + self.rerender(); + }; + + const surface_colors = [ + {'label': 'Pavement - Concrete', 'value': '#9a9a96'}, + {'label': 'Pavement - Asphalt', 'value': '#1f2328'}, + {'label': 'Pavers - Brick', 'value': '#7a2f2a'}, + {'label': 'Pavers - Stone', 'value': '#8f877e'}, + {'label': 'Wood - Light', 'value': '#c79a6b'}, + {'label': 'Wood - Dark', 'value': '#4b2f1f'}, + {'label': 'Mulch - Brown', 'value': '#6b4a2f'}, + {'label': 'Mulch - Red', 'value': '#7a3f32'}, + {'label': 'Mulch - Black', 'value': '#2e3136'}, + {'label': 'Water - Pool', 'value': '#3e89b8'}, + {'label': 'Water - Natural', 'value': '#3f6f5b'} + ]; + surface_colors.sort(function(a, b) { + return a.label.localeCompare(b.label, 'en', {'sensitivity': 'base'}); + }); + surface_colors.push({'label': 'Custom', 'value': '__custom__'}); + + const preset_color_map = {}; surface_colors.forEach(function(surface_color) { + if (surface_color.value !== '__custom__') { + preset_color_map[surface_color.value] = true; + } color_input.add_option(surface_color); }); color_input.render(div); - color_input.set_value(surface.color || '#9e9e9e'); + + const custom_color_container = $.createElement('div'); + custom_color_container.style('display', 'none'); + grid.appendChild(custom_color_container); + const custom_color_input = new beestat.component.input.text() + .set_label('Custom Hex') + .set_placeholder('#RRGGBB') + .set_width('100%') + .set_maxlength(7) + .render(custom_color_container); + + const current_surface_color = normalize_hex_color(surface.color) || '#9a9a96'; + const is_preset_color = preset_color_map[current_surface_color] === true; + + if (is_preset_color === true) { + color_input.set_value(current_surface_color); + custom_color_input.set_value('', false); + custom_color_container.style('display', 'none'); + } else { + color_input.set_value('__custom__'); + custom_color_input.set_value(current_surface_color, false); + custom_color_container.style('display', 'block'); + } color_input.addEventListener('change', function() { - surface.color = color_input.get_value(); - self.floor_plan_.update_infobox(); - self.update_floor_plan_(); - self.rerender(); + const selected_value = color_input.get_value(); + + if (selected_value === '__custom__') { + const custom_color = normalize_hex_color(custom_color_input.get_value()) || + normalize_hex_color(surface.color) || + '#9a9a96'; + custom_color_input.set_value(custom_color, false); + custom_color_container.style('display', 'block'); + custom_color_input.input_.focus(); + return; + } + + custom_color_input.set_value('', false); + custom_color_container.style('display', 'none'); + apply_surface_color(selected_value); + }); + + custom_color_input.addEventListener('change', function() { + if (color_input.get_value() !== '__custom__') { + return; + } + + const custom_color = normalize_hex_color(custom_color_input.get_value()); + if (custom_color === undefined) { + custom_color_input.set_value(surface.color || '#9a9a96', false); + return; + } + + custom_color_input.set_value(custom_color, false); + apply_surface_color(custom_color); }); // Elevation diff --git a/js/component/floor_plan.js b/js/component/floor_plan.js index 9da1678..06a0bca 100644 --- a/js/component/floor_plan.js +++ b/js/component/floor_plan.js @@ -66,14 +66,27 @@ beestat.component.floor_plan.prototype.render = function(parent) { this.update_view_box_(); this.toolbar_container_ = $.createElement('div'); + const toolbar_left = beestat.style.size.gutter + (beestat.style.size.gutter / 2); + const toolbar_column_width = 40; + const toolbar_column_gap = beestat.style.size.gutter / 2; + const toolbar_row_offset = toolbar_column_width; this.toolbar_container_.style({ 'position': 'absolute', 'top': beestat.style.size.gutter, - 'left': beestat.style.size.gutter + (beestat.style.size.gutter / 2), + 'left': toolbar_left, 'width': '40px' }); parent.appendChild(this.toolbar_container_); + this.toolbar_container_secondary_ = $.createElement('div'); + this.toolbar_container_secondary_.style({ + 'position': 'absolute', + 'top': beestat.style.size.gutter + toolbar_row_offset, + 'left': toolbar_left + toolbar_column_width + toolbar_column_gap, + 'width': '40px' + }); + parent.appendChild(this.toolbar_container_secondary_); + this.floors_container_ = $.createElement('div'); this.floors_container_.style({ 'position': 'absolute', @@ -112,22 +125,27 @@ beestat.component.floor_plan.prototype.render = function(parent) { } else if (e.key === 'Delete') { if (self.state_.active_point_entity !== undefined) { self.remove_point_(); - } else if (self.state_.active_surface_entity !== undefined) { - self.remove_surface_(); - } else if (self.state_.active_room_entity !== undefined) { - self.remove_room_(); - } else if (self.state_.active_tree_entity !== undefined) { - self.remove_tree_(); + } else { + self.remove_active_entity_(); } } else if (e.key.toLowerCase() === 'r') { if (e.ctrlKey === false) { self.add_room_(); } + } else if (e.key.toLowerCase() === 'f') { + if (e.ctrlKey === false && beestat.user.has_early_access() === true) { + self.add_surface_(); + } + } else if (e.key.toLowerCase() === 't') { + if (e.ctrlKey === false && beestat.user.has_early_access() === true) { + self.add_tree_(); + } } else if (e.key.toLowerCase() === 's') { self.toggle_snapping_(); } else if ( e.key.toLowerCase() === 'c' && e.ctrlKey === true && + beestat.user.has_early_access() === true && self.state_.active_surface_entity !== undefined ) { self.state_.copied_object = { @@ -146,6 +164,7 @@ beestat.component.floor_plan.prototype.render = function(parent) { } else if ( e.key.toLowerCase() === 'c' && e.ctrlKey === true && + beestat.user.has_early_access() === true && self.state_.active_tree_entity !== undefined ) { self.state_.copied_object = { @@ -155,6 +174,7 @@ beestat.component.floor_plan.prototype.render = function(parent) { } else if ( e.key.toLowerCase() === 'v' && e.ctrlKey === true && + beestat.user.has_early_access() === true && self.state_.copied_object !== undefined && self.state_.copied_object.type === 'surface' ) { @@ -162,6 +182,7 @@ beestat.component.floor_plan.prototype.render = function(parent) { } else if ( e.key.toLowerCase() === 'v' && e.ctrlKey === true && + beestat.user.has_early_access() === true && self.state_.copied_object !== undefined && self.state_.copied_object.type === 'tree' ) { @@ -450,6 +471,7 @@ beestat.component.floor_plan.prototype.dispose = function() { beestat.component.floor_plan.prototype.update_toolbar = function() { const self = this; const tree_group = this.get_tree_group_(); + const has_early_access = beestat.user.has_early_access() === true; if (this.tile_group_ !== undefined) { this.tile_group_.dispose(); @@ -458,8 +480,12 @@ beestat.component.floor_plan.prototype.update_toolbar = function() { if (this.tile_group_floors_ !== undefined) { this.tile_group_floors_.dispose(); } + if (this.tile_group_secondary_ !== undefined) { + this.tile_group_secondary_.dispose(); + } this.tile_group_ = new beestat.component.tile_group(); + this.tile_group_secondary_ = new beestat.component.tile_group(); // Add floor this.tile_group_.add_tile(new beestat.component.tile() @@ -480,49 +506,55 @@ beestat.component.floor_plan.prototype.update_toolbar = function() { }) ); - // Add surface - this.tile_group_.add_tile(new beestat.component.tile() - .set_icon('border_none_variant') - .set_title('Add Surface') - .set_text_color(beestat.style.color.gray.light) - .set_background_color(beestat.style.color.bluegray.base) - .set_background_hover_color(beestat.style.color.bluegray.light) - .addEventListener('click', function() { - self.add_surface_(); - }) - ); - - // Remove room - const remove_room_button = new beestat.component.tile() - .set_icon('card_remove_outline') - .set_title('Remove Room [Delete]') - .set_background_color(beestat.style.color.bluegray.base); - this.tile_group_.add_tile(remove_room_button); - - if (this.state_.active_room_entity !== undefined) { - remove_room_button + if (has_early_access === true) { + // Add surface + this.tile_group_.add_tile(new beestat.component.tile() + .set_icon('texture_box') + .set_title('Add Surface [F]') + .set_text_color(beestat.style.color.gray.light) + .set_background_color(beestat.style.color.bluegray.base) .set_background_hover_color(beestat.style.color.bluegray.light) - .set_text_color(beestat.style.color.red.base) - .addEventListener('click', this.remove_room_.bind(this)); - } else { - remove_room_button - .set_text_color(beestat.style.color.bluegray.dark); + .addEventListener('click', function() { + self.add_surface_(); + }) + ); + + // Add tree (first floor only) + const add_tree_button = new beestat.component.tile() + .set_icon('tree') + .set_title('Add Tree [T]') + .set_background_color(beestat.style.color.bluegray.base); + this.tile_group_.add_tile(add_tree_button); + + if (this.state_.active_group === tree_group) { + add_tree_button + .set_background_hover_color(beestat.style.color.bluegray.light) + .set_text_color(beestat.style.color.gray.light) + .addEventListener('click', this.add_tree_.bind(this)); + } else { + add_tree_button + .set_text_color(beestat.style.color.bluegray.dark); + } } - // Remove surface - const remove_surface_button = new beestat.component.tile() + // Remove selected room, surface, or tree + const remove_button = new beestat.component.tile() .set_icon('card_remove_outline') - .set_title('Remove Surface [Delete]') + .set_title('Remove [Delete]') .set_background_color(beestat.style.color.bluegray.base); - this.tile_group_.add_tile(remove_surface_button); + this.tile_group_.add_tile(remove_button); - if (this.state_.active_surface_entity !== undefined) { - remove_surface_button + if ( + this.state_.active_room_entity !== undefined || + this.state_.active_surface_entity !== undefined || + this.state_.active_tree_entity !== undefined + ) { + remove_button .set_background_hover_color(beestat.style.color.bluegray.light) .set_text_color(beestat.style.color.red.base) - .addEventListener('click', this.remove_surface_.bind(this)); + .addEventListener('click', this.remove_active_entity_.bind(this)); } else { - remove_surface_button + remove_button .set_text_color(beestat.style.color.bluegray.dark); } @@ -589,7 +621,7 @@ beestat.component.floor_plan.prototype.update_toolbar = function() { .set_icon('undo') .set_title('Undo [Ctrl+Z]') .set_background_color(beestat.style.color.bluegray.base); - this.tile_group_.add_tile(undo_button); + this.tile_group_secondary_.add_tile(undo_button); if ( this.can_undo_() === true @@ -610,7 +642,7 @@ beestat.component.floor_plan.prototype.update_toolbar = function() { .set_icon('redo') .set_title('Redo [Ctrl+Y]') .set_background_color(beestat.style.color.bluegray.base); - this.tile_group_.add_tile(redo_button); + this.tile_group_secondary_.add_tile(redo_button); if ( this.can_redo_() === true @@ -631,7 +663,7 @@ beestat.component.floor_plan.prototype.update_toolbar = function() { .set_icon('magnify_plus_outline') .set_title('Zoom In') .set_background_color(beestat.style.color.bluegray.base); - this.tile_group_.add_tile(zoom_in_button); + this.tile_group_secondary_.add_tile(zoom_in_button); if ( this.can_zoom_in_() === true @@ -652,7 +684,7 @@ beestat.component.floor_plan.prototype.update_toolbar = function() { .set_icon('magnify_minus_outline') .set_title('Zoom out') .set_background_color(beestat.style.color.bluegray.base); - this.tile_group_.add_tile(zoom_out_button); + this.tile_group_secondary_.add_tile(zoom_out_button); if ( this.can_zoom_out_() === true @@ -668,25 +700,9 @@ beestat.component.floor_plan.prototype.update_toolbar = function() { .set_text_color(beestat.style.color.bluegray.dark); } - // Add tree (first floor only) - const add_tree_button = new beestat.component.tile() - .set_icon('tree') - .set_title('Add Tree') - .set_background_color(beestat.style.color.bluegray.base); - this.tile_group_.add_tile(add_tree_button); - - if (this.state_.active_group === tree_group) { - add_tree_button - .set_background_hover_color(beestat.style.color.bluegray.light) - .set_text_color(beestat.style.color.gray.light) - .addEventListener('click', this.add_tree_.bind(this)); - } else { - add_tree_button - .set_text_color(beestat.style.color.bluegray.dark); - } - // Render this.tile_group_.render(this.toolbar_container_); + this.tile_group_secondary_.render(this.toolbar_container_secondary_); // FLOORS this.tile_group_floors_ = new beestat.component.tile_group(); @@ -810,6 +826,10 @@ beestat.component.floor_plan.prototype.toggle_snapping_ = function() { * @param {object} surface Optional surface to copy from. */ beestat.component.floor_plan.prototype.add_surface_ = function(surface) { + if (beestat.user.has_early_access() !== true) { + return; + } + this.save_buffer(); if (this.state_.active_group.surfaces === undefined) { @@ -825,7 +845,7 @@ beestat.component.floor_plan.prototype.add_surface_ = function(surface) { 'surface_id': window.crypto.randomUUID(), 'x': svg_view_box.x + (svg_view_box.width / 2) - (new_surface_size / 2), 'y': svg_view_box.y + (svg_view_box.height / 2) - (new_surface_size / 2), - 'color': '#9e9e9e', + 'color': '#9a9a96', 'height': 0, 'points': [ { @@ -863,7 +883,7 @@ beestat.component.floor_plan.prototype.add_surface_ = function(surface) { 'surface_id': window.crypto.randomUUID(), 'x': svg_view_box.x + (svg_view_box.width / 2) - ((max_x - min_x) / 2), 'y': svg_view_box.y + (svg_view_box.height / 2) - ((max_y - min_y) / 2), - 'color': surface.color || '#9e9e9e', + 'color': surface.color || '#9a9a96', 'height': surface.height || 0, 'points': beestat.clone(surface.points) }; @@ -984,6 +1004,25 @@ beestat.component.floor_plan.prototype.remove_room_ = function() { this.dispatchEvent('remove_room'); }; +/** + * Remove the currently active selectable entity (surface, room, or tree). + */ +beestat.component.floor_plan.prototype.remove_active_entity_ = function() { + if (this.state_.active_surface_entity !== undefined) { + this.remove_surface_(); + return; + } + + if (this.state_.active_room_entity !== undefined) { + this.remove_room_(); + return; + } + + if (this.state_.active_tree_entity !== undefined) { + this.remove_tree_(); + } +}; + /** * Remove the currently active surface. */ @@ -1026,6 +1065,10 @@ beestat.component.floor_plan.prototype.remove_surface_ = function() { * @param {object} tree Optional tree to copy from. */ beestat.component.floor_plan.prototype.add_tree_ = function(tree) { + if (beestat.user.has_early_access() !== true) { + return; + } + const tree_group = this.get_tree_group_(); if (tree_group === undefined || this.state_.active_group !== tree_group) { return; diff --git a/js/component/floor_plan_entity/surface.js b/js/component/floor_plan_entity/surface.js index 7a81839..8b326d5 100644 --- a/js/component/floor_plan_entity/surface.js +++ b/js/component/floor_plan_entity/surface.js @@ -30,7 +30,7 @@ beestat.component.floor_plan_entity.surface.prototype.decorate_polygon_ = functi } else if (this.enabled_ === true) { this.polygon_.style.cursor = 'pointer'; this.polygon_.style.fillOpacity = '0.5'; - this.polygon_.style.fill = this.surface_.color || '#9e9e9e'; + this.polygon_.style.fill = this.surface_.color || '#9a9a96'; this.polygon_.style.stroke = beestat.style.color.gray.base; } else { this.polygon_.style.cursor = 'default'; @@ -82,7 +82,7 @@ beestat.component.floor_plan_entity.surface.prototype.set_surface = function(sur } if (this.surface_.color === undefined) { - this.surface_.color = '#9e9e9e'; + this.surface_.color = '#9a9a96'; } if (this.surface_.height === undefined) { this.surface_.height = 0; @@ -108,6 +108,10 @@ beestat.component.floor_plan_entity.surface.prototype.get_surface = function() { * @return {beestat.component.floor_plan_entity.surface} This. */ beestat.component.floor_plan_entity.surface.prototype.set_active = function(active) { + if (active === true && this.enabled_ !== true) { + return this; + } + if (this.state_.active_point_entity !== undefined) { this.state_.active_point_entity.set_active(false); this.floor_plan_.update_toolbar(); diff --git a/js/component/floor_plan_entity/tree.js b/js/component/floor_plan_entity/tree.js index c02b653..2a518a2 100644 --- a/js/component/floor_plan_entity/tree.js +++ b/js/component/floor_plan_entity/tree.js @@ -161,6 +161,19 @@ beestat.component.floor_plan_entity.tree.prototype.set_group = function(group) { return this; }; +/** + * Set enabled (different than active). + * + * @param {boolean} enabled + * + * @return {beestat.component.floor_plan_entity.tree} This. + */ +beestat.component.floor_plan_entity.tree.prototype.set_enabled = function(enabled) { + this.enabled_ = enabled; + + return this; +}; + /** * Get the tree. * @@ -178,6 +191,10 @@ beestat.component.floor_plan_entity.tree.prototype.get_tree = function() { * @return {beestat.component.floor_plan_entity.tree} This. */ beestat.component.floor_plan_entity.tree.prototype.set_active = function(active) { + if (active === true && this.enabled_ !== true) { + return this; + } + if (active !== this.active_) { this.active_ = active; diff --git a/js/component/scene.js b/js/component/scene.js index b516078..cff7454 100644 --- a/js/component/scene.js +++ b/js/component/scene.js @@ -377,7 +377,7 @@ beestat.component.scene.prototype.get_snow_cover_blend_ = function() { }; /** - * Blend roof and ground surface materials toward snow white. + * Blend roof, ground, and floor-plan surface materials toward snow white. * * @param {number} snow_blend */ @@ -386,7 +386,10 @@ beestat.component.scene.prototype.update_snow_surface_colors_ = function(snow_bl return; } - const blend = Math.max(0, Math.min(1, snow_blend)); + // 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')); @@ -411,12 +414,38 @@ beestat.component.scene.prototype.update_snow_surface_colors_ = function(snow_bl this.layers_.environment.traverse(function(object) { if ( object.userData !== undefined && - object.userData.is_ground_surface === true && + 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); + } }); } }; @@ -1879,7 +1908,7 @@ beestat.component.scene.prototype.add_surface_ = function(layer, group, surface) } shape.closePath(); - const color = surface.color || '#9e9e9e'; + 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; @@ -1917,6 +1946,7 @@ beestat.component.scene.prototype.add_surface_ = function(layer, group, surface) mesh.castShadow = true; mesh.userData.is_environment = true; mesh.userData.is_surface = true; + mesh.userData.base_surface_color = color; layer.add(mesh); }; @@ -2130,12 +2160,44 @@ beestat.component.scene.prototype.update_debug_ = function() { } }; +/** + * 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 = beestat.floor_plan.get_bounding_box(this.floor_plan_id_); + const bounding_box = this.get_scene_bounding_box_(); // Main group handles orientation and centering this.main_group_ = new THREE.Group(); @@ -3317,6 +3379,8 @@ beestat.component.scene.prototype.create_pine_tree_ = function(height, max_diame 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; @@ -3483,6 +3547,7 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam 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); const wood_material = new THREE.MeshStandardMaterial({ 'color': 0x6a4d2f, @@ -3492,7 +3557,6 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam }); const trunk_height = height * 0.75; - const size_scale = trunk_height / Math.max(1, height); const trunk_radius_bottom = Math.max(1.5, trunk_height * 0.03); const trunk_stick = this.create_stick_mesh_({ 'height': trunk_height, @@ -3551,7 +3615,10 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam positions.needsUpdate = true; geometry.computeVertexNormals(); - return new THREE.Mesh(geometry, foliage_material.clone()); + const foliage_mesh = new THREE.Mesh(geometry, foliage_material.clone()); + foliage_mesh.userData.is_tree_foliage = true; + foliage_mesh.userData.base_tree_foliage_color = foliage_mesh.material.color.getHex(); + return foliage_mesh; }; const branch_height_samples = []; const branch_tips = []; @@ -3559,6 +3626,9 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam if (has_foliage === true && this.tree_foliage_meshes_ === undefined) { this.tree_foliage_meshes_ = []; } + if (has_foliage === true && this.tree_branch_groups_ === undefined) { + this.tree_branch_groups_ = []; + } for (let i = 0; i < branch_count; i++) { const stratified = (i + 0.5) / branch_count; @@ -3601,6 +3671,22 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam }; const create_branch = function(base, direction, length, radius_bottom, depth) { + 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, @@ -3657,6 +3743,9 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam child_radius_bottom, depth + 1 ); + if (child_branch === null) { + continue; + } branch_tips.push(get_stick_point_world(child_branch, 1)); add_sub_branches(child_branch, depth + 1); } @@ -3666,7 +3755,16 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam const t = branch_height_samples[i]; const base_height = trunk_height * (0.5 + (t * 0.45)); const base_offset = this.sample_stick_curve_offset_(trunk_stick.curve, base_height); - const branch_length = Math.max(8, (max_diameter * (0.75 - (t * 0.34))) * size_scale); + // Scale branch length by both canopy diameter and total tree height so + // taller trees do not end up with disproportionately short limbs. + const height_to_diameter_ratio = height / Math.max(1, max_diameter); + const branch_height_scale = Math.max(0.75, Math.min(1.9, height_to_diameter_ratio / 1.4)); + // Stronger nonlinear taper so upper branches are visibly shorter. + const vertical_taper = Math.pow(1 - t, 1.35); + const branch_length = Math.max( + 4, + (max_diameter * (0.2 + (0.8 * vertical_taper))) * branch_height_scale + ); const branch_radius_bottom = Math.max(0.35, trunk_radius_bottom * (0.42 - (t * 0.26))); const azimuth = ((i / branch_count) * Math.PI * 2) + ((Math.random() - 0.5) * 0.35); const elevation = (Math.PI / 180) * (16 + (Math.random() * 10)); @@ -3678,11 +3776,14 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam const base = new THREE.Vector3(base_offset.x, base_offset.y, -base_height); const primary_branch = create_branch(base, direction, branch_length, branch_radius_bottom, 0); + if (primary_branch === null) { + continue; + } branch_tips.push(get_stick_point_world(primary_branch, 1)); add_sub_branches(primary_branch, 0); } - if (has_foliage === true) { + if (has_foliage === true) { const core_height = trunk_height * 0.75; const core_offset = this.sample_stick_curve_offset_(trunk_stick.curve, core_height); const core_center = new THREE.Vector3(core_offset.x, core_offset.y, -core_height); @@ -3693,7 +3794,7 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam coverage_radius = distance; } } - const core_radius = Math.max(20, coverage_radius * 1.03); + const core_radius = Math.min(max_canopy_radius, Math.max(4, coverage_radius * 1.03)); const core_blob = create_foliage_blob(core_radius, 0.18); core_blob.position.copy(core_center); core_blob.scale.set( @@ -3711,6 +3812,10 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam this.tree_foliage_meshes_.push(core_blob); } + if (has_foliage === true) { + this.tree_branch_groups_.push(branches); + } + branches.visible = has_foliage !== true; tree.add(branches); if (has_foliage === true) { tree.add(foliage); @@ -3764,21 +3869,35 @@ beestat.component.scene.prototype.get_tree_foliage_state_ = function() { * Apply seasonal foliage appearance to deciduous canopy meshes. */ beestat.component.scene.prototype.update_tree_foliage_season_ = function() { - if (this.tree_foliage_meshes_ === undefined || this.tree_foliage_meshes_.length === 0) { + 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_(); - 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; + 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 = 1; + mesh.material.transparent = false; + 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) { + branch_group.visible = state.visible !== true; + } } - mesh.material.color.copy(state.color); - mesh.material.opacity = 1; - mesh.material.transparent = false; - mesh.material.needsUpdate = true; - mesh.visible = state.visible; } }; @@ -3793,6 +3912,7 @@ beestat.component.scene.prototype.add_trees_ = function(ground_surface_z) { 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; @@ -3831,7 +3951,7 @@ beestat.component.scene.prototype.add_trees_ = function(ground_surface_z) { */ beestat.component.scene.prototype.add_environment_ = function() { const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_]; - const bounding_box = beestat.floor_plan.get_bounding_box(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; @@ -3886,7 +4006,7 @@ beestat.component.scene.prototype.add_environment_ = function() { mesh.position.z = current_z + stratum.thickness / 2; mesh.userData.is_environment = true; if (index === 0) { - mesh.userData.is_ground_surface = true; + mesh.userData.is_ground = true; } mesh.receiveShadow = true;