From c46f5dbb9e65a7d0e02f1b4d3ff3d8133dce65f0 Mon Sep 17 00:00:00 2001 From: Jon Ziebell Date: Thu, 19 Feb 2026 22:02:40 -0500 Subject: [PATCH] Lights --- css/dashboard.css | 13 +- js/component/card/floor_plan_editor.js | 458 +++++++++++-- js/component/card/three_d.js | 16 +- js/component/floor_plan.js | 256 +++++++- .../floor_plan_entity/light_source.js | 446 +++++++++++++ js/component/floor_plan_entity/opening.js | 583 ++++++++++++++--- js/component/floor_plan_entity/room.js | 20 + js/component/floor_plan_entity/surface.js | 11 +- js/component/floor_plan_layers_sidebar.js | 33 + js/component/scene.js | 616 +++++++++++++++++- js/js.php | 1 + 11 files changed, 2251 insertions(+), 202 deletions(-) create mode 100644 js/component/floor_plan_entity/light_source.js diff --git a/css/dashboard.css b/css/dashboard.css index aeb364e..f4d154a 100644 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -491,12 +491,13 @@ input[type=range]::-moz-range-thumb { .icon.information:before { content: "\F02FC"; } .icon.key:before { content: "\F0306"; } .icon.label:before { content: "\F0315"; } -.icon.label:before { content: "\F0315"; } -.icon.label_off:before { content: "\F0ACB"; } -.icon.layers:before { content: "\F0328"; } -.icon.layers_plus:before { content: "\F0E4D"; } -.icon.link_off:before { content: "\F0338"; } -.icon.magnify_close:before { content: "\F0980"; } +.icon.label:before { content: "\F0315"; } +.icon.label_off:before { content: "\F0ACB"; } +.icon.layers:before { content: "\F0328"; } +.icon.layers_plus:before { content: "\F0E4D"; } +.icon.lightbulb_on:before { content: "\F06E8"; } +.icon.link_off:before { content: "\F0338"; } +.icon.magnify_close:before { content: "\F0980"; } .icon.magnify_minus_outline:before { content: "\F06EC"; } .icon.magnify_plus_outline:before { content: "\F06ED"; } .icon.map_marker:before { content: "\F05F8"; } diff --git a/js/component/card/floor_plan_editor.js b/js/component/card/floor_plan_editor.js index 7110f47..84187ff 100644 --- a/js/component/card/floor_plan_editor.js +++ b/js/component/card/floor_plan_editor.js @@ -85,6 +85,9 @@ beestat.component.card.floor_plan_editor.prototype.decorate_contents_ = function if (group.openings === undefined) { group.openings = []; } + if (group.light_sources === undefined) { + group.light_sources = []; + } group.rooms.forEach(function(room) { if (room.room_id === undefined) { @@ -136,15 +139,88 @@ beestat.component.card.floor_plan_editor.prototype.decorate_contents_ = function if (opening.editor_locked === undefined) { opening.editor_locked = false; } - if (['empty', 'door', 'window'].includes(opening.type) !== true) { + if (opening.type === 'garage') { + opening.type = 'door'; + } + if (['empty', 'door', 'window', 'glass'].includes(opening.type) !== true) { opening.type = 'empty'; } + const is_window_like = opening.type === 'window' || opening.type === 'glass'; + const default_opening_width = is_window_like ? 48 : 36; + const default_opening_height = is_window_like ? 42 : 78; + const default_opening_elevation = is_window_like ? 36 : 0; + const default_opening_color = '#7a573b'; + const center_x = Number(opening.x || 0); + const center_y = Number(opening.y || 0); + const width = Number(opening.width || default_opening_width); + const rotation_radians = (Number(opening.rotation || 0) * Math.PI) / 180; if (opening.width === undefined) { - opening.width = 36; + opening.width = default_opening_width; } if (opening.height === undefined) { - opening.height = 80; + opening.height = default_opening_height; } + if (opening.elevation === undefined) { + opening.elevation = default_opening_elevation; + } + if (opening.rotation === undefined) { + opening.rotation = 0; + } + if ( + opening.points === undefined || + Array.isArray(opening.points) !== true || + opening.points.length !== 2 + ) { + const half_width = Math.max(12, width) / 2; + const axis_x = Math.cos(rotation_radians); + const axis_y = Math.sin(rotation_radians); + opening.points = [ + { + 'x': center_x - (axis_x * half_width), + 'y': center_y - (axis_y * half_width) + }, + { + 'x': center_x + (axis_x * half_width), + 'y': center_y + (axis_y * half_width) + } + ]; + } + opening.x = (Number(opening.points[0].x || 0) + Number(opening.points[1].x || 0)) / 2; + opening.y = (Number(opening.points[0].y || 0) + Number(opening.points[1].y || 0)) / 2; + const dx = Number(opening.points[1].x || 0) - Number(opening.points[0].x || 0); + const dy = Number(opening.points[1].y || 0) - Number(opening.points[0].y || 0); + opening.width = Math.max(12, Math.round(Math.sqrt((dx * dx) + (dy * dy)))); + delete opening.rotation; + if (opening.type === 'door') { + if (opening.color === undefined) { + opening.color = default_opening_color; + } + } else { + delete opening.color; + } + }); + + group.light_sources.forEach(function(light_source) { + if (light_source.light_source_id === undefined) { + light_source.light_source_id = window.crypto.randomUUID(); + } + if (light_source.editor_hidden === undefined) { + light_source.editor_hidden = light_source.editor_visible === false; + } + delete light_source.editor_visible; + if (light_source.editor_locked === undefined) { + light_source.editor_locked = false; + } + light_source.x = Number(light_source.x || 0); + light_source.y = Number(light_source.y || 0); + light_source.elevation = Number(light_source.elevation !== undefined ? light_source.elevation : 84); + light_source.intensity = ['dim', 'normal', 'bright'].includes(light_source.intensity) + ? light_source.intensity + : 'normal'; + light_source.temperature_k = Math.max( + 1000, + Math.min(12000, Math.round(Number(light_source.temperature_k || 4000))) + ); }); }); @@ -450,6 +526,14 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func self.update_floor_plan_(); self.rerender(); }); + this.floor_plan_.addEventListener('add_light_source', function() { + self.update_floor_plan_(); + self.rerender(); + }); + this.floor_plan_.addEventListener('remove_light_source', function() { + self.update_floor_plan_(); + self.rerender(); + }); this.floor_plan_.addEventListener('undo', function() { self.update_floor_plan_(); self.rerender(); @@ -464,7 +548,8 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func 'rooms': {}, 'surfaces': {}, 'trees': {}, - 'openings': {} + 'openings': {}, + 'light_sources': {} }; const on_entity_update = function() { @@ -529,6 +614,17 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func .set_group(self.state_.active_group); opening_entity.render(self.floor_plan_.get_g()); }); + (group_below.light_sources || []).slice().reverse().forEach(function(light_source) { + if (light_source.editor_hidden === true) { + return; + } + + const light_source_entity = new beestat.component.floor_plan_entity.light_source(self.floor_plan_, self.state_) + .set_enabled(false) + .set_light_source(light_source) + .set_group(self.state_.active_group); + light_source_entity.render(self.floor_plan_.get_g()); + }); } // Loop over the rooms in this group and add them. @@ -652,6 +748,42 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func this.state_.active_opening_entity.render(this.floor_plan_.get_g()); } + // Loop over light sources in this group and add them. + let active_light_source_entity; + (this.state_.active_group.light_sources || []).slice().reverse().forEach(function(light_source) { + if (light_source.editor_hidden === true) { + return; + } + + const light_source_entity = new beestat.component.floor_plan_entity.light_source(self.floor_plan_, self.state_) + .set_enabled(light_source.editor_locked !== true) + .set_light_source(light_source) + .set_group(self.state_.active_group); + + light_source_entity.addEventListener('update', on_entity_update); + light_source_entity.addEventListener('activate', on_entity_activate); + light_source_entity.addEventListener('inactivate', on_entity_inactivate); + + if ( + self.state_.active_light_source_entity !== undefined && + light_source.light_source_id === self.state_.active_light_source_entity.get_light_source().light_source_id + ) { + delete self.state_.active_light_source_entity; + active_light_source_entity = light_source_entity; + } + + light_source_entity.render(self.floor_plan_.get_g()); + self.entity_index_.light_sources[light_source.light_source_id] = light_source_entity; + }); + + if (active_light_source_entity !== undefined) { + active_light_source_entity.set_active(true); + } + + if (this.state_.active_light_source_entity !== undefined) { + this.state_.active_light_source_entity.render(this.floor_plan_.get_g()); + } + // Trees are only editable on the first floor. const tree_group = this.floor_plan_.get_tree_group_(); if (tree_group === this.state_.active_group) { @@ -722,6 +854,8 @@ beestat.component.card.floor_plan_editor.prototype.select_layer_object_ = functi normalized_type = 'rooms'; } else if (normalized_type === 'opening') { normalized_type = 'openings'; + } else if (normalized_type === 'light_source') { + normalized_type = 'light_sources'; } const object = this.get_layer_object_by_id_(group, normalized_type, object_id); const is_active_group = ( @@ -796,6 +930,13 @@ beestat.component.card.floor_plan_editor.prototype.set_layer_object_visibility_ ) { this.state_.active_opening_entity.set_active(false); } + if ( + type === 'light_sources' && + this.state_.active_light_source_entity !== undefined && + this.state_.active_light_source_entity.get_light_source().light_source_id === object_id + ) { + this.state_.active_light_source_entity.set_active(false); + } } this.floor_plan_.update_infobox(); @@ -850,6 +991,13 @@ beestat.component.card.floor_plan_editor.prototype.set_layer_object_locked_ = fu ) { this.state_.active_opening_entity.set_active(false); } + if ( + type === 'light_sources' && + this.state_.active_light_source_entity !== undefined && + this.state_.active_light_source_entity.get_light_source().light_source_id === object_id + ) { + this.state_.active_light_source_entity.set_active(false); + } } this.floor_plan_.update_infobox(); @@ -906,7 +1054,7 @@ beestat.component.card.floor_plan_editor.prototype.set_layer_visible_ = function * @param {boolean} locked */ beestat.component.card.floor_plan_editor.prototype.set_group_locked_ = function(group, locked) { - ['rooms', 'surfaces', 'openings', 'trees'].forEach(function(type) { + ['rooms', 'surfaces', 'openings', 'trees', 'light_sources'].forEach(function(type) { const collection = group[type] || []; collection.forEach(function(object) { object.editor_locked = locked; @@ -918,6 +1066,7 @@ beestat.component.card.floor_plan_editor.prototype.set_group_locked_ = function( this.deactivate_active_entity_for_group_type_(group, 'surfaces'); this.deactivate_active_entity_for_group_type_(group, 'openings'); this.deactivate_active_entity_for_group_type_(group, 'trees'); + this.deactivate_active_entity_for_group_type_(group, 'light_sources'); } this.sync_after_layer_change_(); @@ -930,7 +1079,7 @@ beestat.component.card.floor_plan_editor.prototype.set_group_locked_ = function( * @param {boolean} visible */ beestat.component.card.floor_plan_editor.prototype.set_group_visible_ = function(group, visible) { - ['rooms', 'surfaces', 'openings', 'trees'].forEach(function(type) { + ['rooms', 'surfaces', 'openings', 'trees', 'light_sources'].forEach(function(type) { const collection = group[type] || []; collection.forEach(function(object) { object.editor_hidden = visible !== true; @@ -942,6 +1091,7 @@ beestat.component.card.floor_plan_editor.prototype.set_group_visible_ = function this.deactivate_active_entity_for_group_type_(group, 'surfaces'); this.deactivate_active_entity_for_group_type_(group, 'openings'); this.deactivate_active_entity_for_group_type_(group, 'trees'); + this.deactivate_active_entity_for_group_type_(group, 'light_sources'); } this.sync_after_layer_change_(); @@ -979,6 +1129,13 @@ beestat.component.card.floor_plan_editor.prototype.deactivate_active_entity_for_ if (this.state_.active_opening_entity.group_ === group) { this.state_.active_opening_entity.set_active(false); } + return; + } + + if (type === 'light_sources' && this.state_.active_light_source_entity !== undefined) { + if (this.state_.active_light_source_entity.group_ === group) { + this.state_.active_light_source_entity.set_active(false); + } } }; @@ -1076,6 +1233,16 @@ beestat.component.card.floor_plan_editor.prototype.ensure_active_entity_visibili ) { delete this.state_.active_opening_entity; } + + if ( + this.state_.active_light_source_entity !== undefined && + ( + this.state_.active_light_source_entity.get_light_source().editor_hidden === true || + this.state_.active_light_source_entity.get_light_source().editor_locked === true + ) + ) { + delete this.state_.active_light_source_entity; + } }; /** @@ -1125,6 +1292,9 @@ beestat.component.card.floor_plan_editor.prototype.get_layer_object_id_key_ = fu if (type === 'openings') { return 'opening_id'; } + if (type === 'light_sources') { + return 'light_source_id'; + } return 'tree_id'; }; @@ -1158,6 +1328,8 @@ beestat.component.card.floor_plan_editor.prototype.expand_layers_for_active_enti let type; if (this.state_.active_tree_entity !== undefined) { type = 'trees'; + } else if (this.state_.active_light_source_entity !== undefined) { + type = 'light_sources'; } else if (this.state_.active_opening_entity !== undefined) { type = 'openings'; } else if (this.state_.active_surface_entity !== undefined) { @@ -1187,6 +1359,9 @@ beestat.component.card.floor_plan_editor.prototype.scroll_layers_to_active_entit if (this.state_.active_tree_entity !== undefined) { type = 'trees'; object_id = this.state_.active_tree_entity.get_tree().tree_id; + } else if (this.state_.active_light_source_entity !== undefined) { + type = 'light_sources'; + object_id = this.state_.active_light_source_entity.get_light_source().light_source_id; } else if (this.state_.active_opening_entity !== undefined) { type = 'openings'; object_id = this.state_.active_opening_entity.get_opening().opening_id; @@ -1250,6 +1425,12 @@ beestat.component.card.floor_plan_editor.prototype.restore_entity_draw_order_ = 'opening_id' ); + append_entities_in_order( + this.state_.active_group.light_sources || [], + this.entity_index_.light_sources || {}, + 'light_source_id' + ); + const tree_group = this.floor_plan_.get_tree_group_(); if (tree_group === this.state_.active_group) { append_entities_in_order( @@ -1268,6 +1449,8 @@ beestat.component.card.floor_plan_editor.prototype.restore_entity_draw_order_ = beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_ = function(parent) { if (this.state_.active_tree_entity !== undefined) { this.decorate_info_pane_tree_(parent); + } else if (this.state_.active_light_source_entity !== undefined) { + this.decorate_info_pane_light_source_(parent); } else if (this.state_.active_opening_entity !== undefined) { this.decorate_info_pane_opening_(parent); } else if (this.state_.active_surface_entity !== undefined) { @@ -1534,6 +1717,140 @@ beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_tree_ = fu }); }; +/** + * Decorate the info pane for a light source. + * + * @param {rocket.Elements} parent + */ +beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_light_source_ = function(parent) { + const self = this; + const light_source = this.state_.active_light_source_entity.get_light_source(); + + const grid = $.createElement('div') + .style({ + 'display': 'grid', + 'grid-template-columns': 'repeat(auto-fit, minmax(150px, 1fr))', + 'column-gap': beestat.style.size.gutter + }); + parent.appendChild(grid); + + const div = $.createElement('div'); + grid.appendChild(div); + const name_input = new beestat.component.input.text() + .set_label('Light Source Name') + .set_placeholder('Unnamed Light Source') + .set_width('100%') + .set_maxlength(50) + .render(div); + + if (light_source.name !== undefined) { + name_input.set_value(light_source.name); + } + + name_input.addEventListener('input', function() { + light_source.name = name_input.get_value(); + self.update_layers_sidebar_(); + }); + name_input.addEventListener('change', function() { + light_source.name = name_input.get_value(); + self.update_floor_plan_(); + self.update_layers_sidebar_(); + }); + + const intensity_div = $.createElement('div'); + grid.appendChild(intensity_div); + const intensity_input = new beestat.component.input.select() + .set_label('Intensity') + .set_width('100%') + .add_option({'label': 'Dim', 'value': 'dim'}) + .add_option({'label': 'Normal', 'value': 'normal'}) + .add_option({'label': 'Bright', 'value': 'bright'}) + .render(intensity_div); + + const normalized_intensity = ['dim', 'normal', 'bright'].includes(light_source.intensity) + ? light_source.intensity + : 'normal'; + intensity_input.set_value(normalized_intensity); + intensity_input.addEventListener('change', function() { + const value = intensity_input.get_value(); + light_source.intensity = ['dim', 'normal', 'bright'].includes(value) ? value : 'normal'; + self.update_floor_plan_(); + }); + + const temperature_div = $.createElement('div'); + grid.appendChild(temperature_div); + const temperature_input = new beestat.component.input.select() + .set_label('Temperature (K)') + .set_width('100%') + .add_option({'label': '2200K (Candle)', 'value': '2200'}) + .add_option({'label': '2700K (Warm)', 'value': '2700'}) + .add_option({'label': '3000K (Soft Warm)', 'value': '3000'}) + .add_option({'label': '3500K (Neutral Warm)', 'value': '3500'}) + .add_option({'label': '4000K (Neutral)', 'value': '4000'}) + .add_option({'label': '5000K (Cool)', 'value': '5000'}) + .add_option({'label': '6500K (Daylight)', 'value': '6500'}) + .render(temperature_div); + + const common_temperatures = [2200, 2700, 3000, 3500, 4000, 5000, 6500]; + const current_temperature = Math.max(1000, Math.min(12000, Math.round(Number(light_source.temperature_k || 4000)))); + let closest_temperature = common_temperatures[0]; + let closest_distance = Math.abs(current_temperature - closest_temperature); + for (let i = 1; i < common_temperatures.length; i++) { + const candidate = common_temperatures[i]; + const distance = Math.abs(current_temperature - candidate); + if (distance < closest_distance) { + closest_distance = distance; + closest_temperature = candidate; + } + } + temperature_input.set_value(String(closest_temperature)); + + temperature_input.addEventListener('change', function() { + light_source.temperature_k = Number(temperature_input.get_value() || 4000); + self.update_floor_plan_(); + }); + + const elevation_div = $.createElement('div'); + grid.appendChild(elevation_div); + const elevation_input = new beestat.component.input.text() + .set_label('Elevation (' + beestat.setting('units.distance') + ')') + .set_width('100%') + .set_maxlength(6) + .set_value(beestat.distance({ + 'distance': Number(light_source.elevation !== undefined ? light_source.elevation : 84), + 'round': 2 + }) || '') + .set_requirements({ + 'type': 'decimal', + 'min_value': beestat.distance(-600), + 'max_value': beestat.distance(600), + 'required': true + }) + .set_transform({ + 'type': 'round', + 'decimals': 2 + }) + .render(elevation_div); + + elevation_input.addEventListener('change', function() { + if (elevation_input.meets_requirements() === true) { + light_source.elevation = beestat.distance({ + 'distance': elevation_input.get_value(), + 'input_distance_unit': beestat.setting('units.distance'), + 'output_distance_unit': 'in', + 'round': 2 + }); + self.update_floor_plan_(); + return; + } + + elevation_input.set_value(beestat.distance({ + 'distance': Number(light_source.elevation !== undefined ? light_source.elevation : 84), + 'round': 2 + }) || '', false); + }); +}; + /** * Decorate the info pane for a surface. * @@ -1827,61 +2144,58 @@ beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_opening_ = const type_input = new beestat.component.input.select() .set_label('Type') .set_width('100%') - .add_option({'label': 'Empty', 'value': 'empty'}) - .add_option({'label': 'Door', 'value': 'door'}) .add_option({'label': 'Window', 'value': 'window'}) + .add_option({'label': 'Door', 'value': 'door'}) + .add_option({'label': 'Glass', 'value': 'glass'}) + .add_option({'label': 'Opening', 'value': 'empty'}) .render(div); - type_input.set_value(['empty', 'door', 'window'].includes(opening.type) ? opening.type : 'empty'); + type_input.set_value(['empty', 'door', 'window', 'glass'].includes(opening.type) ? opening.type : 'empty'); type_input.addEventListener('change', function() { + const previous_type = opening.type; opening.type = type_input.get_value(); + const previous_is_window_like = previous_type === 'window' || previous_type === 'glass'; + const next_is_window_like = opening.type === 'window' || opening.type === 'glass'; + const previous_default_height = previous_is_window_like ? 42 : 78; + const previous_default_elevation = previous_is_window_like ? 36 : 0; + const next_default_height = next_is_window_like ? 42 : 78; + const next_default_elevation = next_is_window_like ? 36 : 0; + if (Number(opening.height || 0) === previous_default_height) { + opening.height = next_default_height; + } + if (Number(opening.elevation || 0) === previous_default_elevation) { + opening.elevation = next_default_elevation; + } + if (opening.type === 'door') { + opening.color = opening.color || '#7a573b'; + } else { + delete opening.color; + } self.update_floor_plan_(); self.rerender(); }); - // Width - div = $.createElement('div'); - grid.appendChild(div); - const width_input = new beestat.component.input.text() - .set_label('Width (' + beestat.setting('units.distance') + ')') - .set_placeholder(beestat.distance({ - 'distance': opening.width || 0, - 'round': 2 - })) - .set_value(beestat.distance({ - 'distance': opening.width || 0, - 'round': 2 - }) || '') - .set_width('100%') - .set_maxlength(5) - .set_requirements({ - 'type': 'decimal', - 'min_value': beestat.distance(1), - 'required': true - }) - .set_transform({ - 'type': 'round', - 'decimals': 2 - }) - .render(div); + if (opening.type === 'door') { + div = $.createElement('div'); + grid.appendChild(div); + const door_color_input = new beestat.component.input.select() + .set_label('Door Color') + .set_width('100%') + .add_option({'label': 'Black', 'value': '#4a4a4a'}) + .add_option({'label': 'Blue', 'value': '#365e9d'}) + .add_option({'label': 'Brown', 'value': '#7a573b'}) + .add_option({'label': 'Gray', 'value': '#808890'}) + .add_option({'label': 'Green', 'value': '#4b6a4b'}) + .add_option({'label': 'Red', 'value': '#8a3e3a'}) + .add_option({'label': 'White', 'value': '#f4f4f2'}) + .render(div); - width_input.addEventListener('change', function() { - if (width_input.meets_requirements() === true) { - opening.width = beestat.distance({ - 'distance': width_input.get_value(), - 'input_distance_unit': beestat.setting('units.distance'), - 'output_distance_unit': 'in', - 'round': 2 - }); + door_color_input.set_value(String(opening.color || '#7a573b')); + door_color_input.addEventListener('change', function() { + opening.color = door_color_input.get_value(); self.update_floor_plan_(); - self.rerender(); - } else { - width_input.set_value(beestat.distance({ - 'distance': opening.width || 0, - 'round': 2 - }) || '', false); - } - }); + }); + } // Height div = $.createElement('div'); @@ -1925,6 +2239,50 @@ beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_opening_ = }) || '', false); } }); + + // Elevation + div = $.createElement('div'); + grid.appendChild(div); + const elevation_input = new beestat.component.input.text() + .set_label('Elevation (' + beestat.setting('units.distance') + ')') + .set_placeholder(beestat.distance({ + 'distance': opening.elevation || 0, + 'round': 2 + })) + .set_value(beestat.distance({ + 'distance': opening.elevation || 0, + 'round': 2 + }) || '') + .set_width('100%') + .set_maxlength(5) + .set_requirements({ + 'type': 'decimal', + 'min_value': beestat.distance(-600), + 'max_value': beestat.distance(600), + 'required': true + }) + .set_transform({ + 'type': 'round', + 'decimals': 2 + }) + .render(div); + + elevation_input.addEventListener('change', function() { + if (elevation_input.meets_requirements() === true) { + opening.elevation = beestat.distance({ + 'distance': elevation_input.get_value(), + 'input_distance_unit': beestat.setting('units.distance'), + 'output_distance_unit': 'in', + 'round': 2 + }); + self.update_floor_plan_(); + } else { + elevation_input.set_value(beestat.distance({ + 'distance': opening.elevation || 0, + 'round': 2 + }) || '', false); + } + }); }; /** diff --git a/js/component/card/three_d.js b/js/component/card/three_d.js index 368a1b6..2a1b0f3 100644 --- a/js/component/card/three_d.js +++ b/js/component/card/three_d.js @@ -408,7 +408,12 @@ beestat.component.card.three_d.prototype.decorate_drawing_pane_ = function(paren // Set some defaults on the scene. this.scene_.set_date(this.date_m_); - this.scene_.set_labels(beestat.setting('visualize.three_d.show_labels')); + this.scene_.set_labels( + this.get_show_environment_() === true + ? false + : beestat.setting('visualize.three_d.show_labels') + ); + this.scene_.set_room_interaction_enabled(this.get_show_environment_() === false); this.scene_.set_auto_rotate(beestat.setting('visualize.three_d.auto_rotate')); const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_]; @@ -563,10 +568,17 @@ beestat.component.card.three_d.prototype.apply_layer_visibility_ = function() { const group_visible = beestat.setting(setting_key) !== false; this.scene_.set_layer_visible( group.group_id, - show_environment === true ? false : group_visible + group_visible ); }); + this.scene_.set_labels( + show_environment === true + ? false + : beestat.setting('visualize.three_d.show_labels') + ); + this.scene_.set_room_interaction_enabled(show_environment === false); + this.update_environment_date_visibility_(); if (this.controls_container_ !== undefined) { diff --git a/js/component/floor_plan.js b/js/component/floor_plan.js index 4e336e8..a80c3d5 100644 --- a/js/component/floor_plan.js +++ b/js/component/floor_plan.js @@ -131,7 +131,8 @@ beestat.component.floor_plan.prototype.render = function(parent) { self.state_.active_room_entity !== undefined || self.state_.active_surface_entity !== undefined || self.state_.active_tree_entity !== undefined || - self.state_.active_opening_entity !== undefined + self.state_.active_opening_entity !== undefined || + self.state_.active_light_source_entity !== undefined ) { self.clear_room_(); } @@ -154,9 +155,13 @@ beestat.component.floor_plan.prototype.render = function(parent) { self.add_tree_(); } } else if (e.key.toLowerCase() === 'o') { - if (e.ctrlKey === false) { + if (e.ctrlKey === false && self.has_early_access_() === true) { self.add_opening_(); } + } else if (e.key.toLowerCase() === 'l') { + if (e.ctrlKey === false && self.has_early_access_() === true) { + self.add_light_source_(); + } } else if (e.key.toLowerCase() === 's') { self.toggle_snapping_(); } else if ( @@ -188,6 +193,24 @@ beestat.component.floor_plan.prototype.render = function(parent) { 'type': 'tree', 'data': beestat.clone(self.state_.active_tree_entity.get_tree()) }; + } else if ( + e.key.toLowerCase() === 'c' && + e.ctrlKey === true && + self.state_.active_opening_entity !== undefined + ) { + self.state_.copied_object = { + 'type': 'opening', + 'data': beestat.clone(self.state_.active_opening_entity.get_opening()) + }; + } else if ( + e.key.toLowerCase() === 'c' && + e.ctrlKey === true && + self.state_.active_light_source_entity !== undefined + ) { + self.state_.copied_object = { + 'type': 'light_source', + 'data': beestat.clone(self.state_.active_light_source_entity.get_light_source()) + }; } else if ( e.key.toLowerCase() === 'v' && e.ctrlKey === true && @@ -211,6 +234,20 @@ beestat.component.floor_plan.prototype.render = function(parent) { self.state_.copied_object.type === 'room' ) { self.add_room_(self.state_.copied_object.data); + } else if ( + e.key.toLowerCase() === 'v' && + e.ctrlKey === true && + self.state_.copied_object !== undefined && + self.state_.copied_object.type === 'opening' + ) { + self.add_opening_(self.state_.copied_object.data); + } else if ( + e.key.toLowerCase() === 'v' && + e.ctrlKey === true && + self.state_.copied_object !== undefined && + self.state_.copied_object.type === 'light_source' + ) { + self.add_light_source_(self.state_.copied_object.data); } else if ( e.key.toLowerCase() === 'z' && e.ctrlKey === true @@ -231,13 +268,23 @@ beestat.component.floor_plan.prototype.render = function(parent) { self.state_.active_point_entity || self.state_.active_wall_entity || self.state_.active_opening_entity || + self.state_.active_light_source_entity || self.state_.active_surface_entity || self.state_.active_room_entity || self.state_.active_tree_entity; if (entity !== undefined) { - const x = entity.get_x(); - const y = entity.get_y(); + let x = entity.get_x(); + let y = entity.get_y(); + if ( + self.state_.active_opening_entity !== undefined && + entity === self.state_.active_opening_entity && + typeof entity.get_center_ === 'function' + ) { + const opening_center = entity.get_center_(); + x = opening_center.x; + y = opening_center.y; + } switch (e.key) { case 'ArrowLeft': @@ -516,19 +563,19 @@ beestat.component.floor_plan.prototype.update_toolbar = function() { }) ); - // Add opening - this.tile_group_.add_tile(new beestat.component.tile() - .set_icon('window_closed_variant') - .set_title('Add Opening [O]') - .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_opening_(); - }) - ); - if (this.has_early_access_() === true) { + // Add opening + this.tile_group_.add_tile(new beestat.component.tile() + .set_icon('window_closed_variant') + .set_title('Add Opening [O]') + .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_opening_(); + }) + ); + // Add surface this.tile_group_.add_tile(new beestat.component.tile() .set_icon('texture_box') @@ -566,6 +613,18 @@ beestat.component.floor_plan.prototype.update_toolbar = function() { add_tree_button .set_text_color(beestat.style.color.bluegray.dark); } + + // Add light source + this.tile_group_.add_tile(new beestat.component.tile() + .set_icon('lightbulb_on') + .set_title('Add Light Source [L]') + .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_light_source_(); + }) + ); } // Remove selected room, opening, surface, or tree @@ -579,7 +638,8 @@ beestat.component.floor_plan.prototype.update_toolbar = function() { this.state_.active_room_entity !== undefined || this.state_.active_opening_entity !== undefined || this.state_.active_surface_entity !== undefined || - this.state_.active_tree_entity !== undefined + this.state_.active_tree_entity !== undefined || + this.state_.active_light_source_entity !== undefined ) { remove_button .set_background_hover_color(beestat.style.color.bluegray.light) @@ -821,15 +881,18 @@ beestat.component.floor_plan.prototype.update_infobox = function() { ); } else if (this.state_.active_opening_entity !== undefined) { const opening = this.state_.active_opening_entity.get_opening(); + const opening_width = this.get_opening_width_(opening); parts.push('Opening'); parts.push((opening.type || 'empty').toUpperCase()); parts.push( beestat.distance({ - 'distance': opening.width || 0, + 'distance': opening_width, 'units': true, 'round': 0 }) + ' w' ); + } else if (this.state_.active_light_source_entity !== undefined) { + parts.push('Light Source'); } else { parts.push(this.state_.active_group.name || 'Unnamed Floor'); parts.push( @@ -1049,6 +1112,11 @@ beestat.component.floor_plan.prototype.remove_room_ = function() { * Remove the currently active selectable entity (surface, room, opening, or tree). */ beestat.component.floor_plan.prototype.remove_active_entity_ = function() { + if (this.state_.active_light_source_entity !== undefined) { + this.remove_light_source_(); + return; + } + if (this.state_.active_opening_entity !== undefined) { this.remove_opening_(); return; @@ -1099,6 +1167,9 @@ beestat.component.floor_plan.prototype.set_active_group = function(group) { if (this.state_.active_opening_entity !== undefined) { this.state_.active_opening_entity.set_active(false); } + if (this.state_.active_light_source_entity !== undefined) { + this.state_.active_light_source_entity.set_active(false); + } this.state_.active_group = group; this.dispatchEvent('change_group'); @@ -1148,6 +1219,10 @@ beestat.component.floor_plan.prototype.remove_surface_ = function() { * @param {object} opening Optional opening to copy from. */ beestat.component.floor_plan.prototype.add_opening_ = function(opening) { + if (this.has_early_access_() !== true) { + return; + } + this.save_buffer(); if (this.state_.active_group.openings === undefined) { @@ -1155,16 +1230,52 @@ beestat.component.floor_plan.prototype.add_opening_ = function(opening) { } const svg_view_box = this.view_box_; - const width = Math.max(12, Number((opening || {}).width || 36)); - const height = Math.max(1, Number((opening || {}).height || 80)); + const requested_opening_type = (opening || {}).type; + const opening_type = requested_opening_type === 'garage' + ? 'door' + : ( + ['empty', 'door', 'window', 'glass'].includes(requested_opening_type) + ? requested_opening_type + : 'empty' + ); + + let default_width = 36; + let default_height = 78; + let default_elevation = 0; + let default_color = '#7a573b'; + if (opening_type === 'window' || opening_type === 'glass') { + default_width = 48; + default_height = 42; + default_elevation = 36; + } + + const width = Math.max(12, Number((opening || {}).width || default_width)); + const height = Math.max(1, Number((opening || {}).height || default_height)); + const elevation = Number((opening || {}).elevation !== undefined ? opening.elevation : default_elevation); + const center_x = Number((opening || {}).x || (svg_view_box.x + (svg_view_box.width / 2))); + const center_y = Number((opening || {}).y || (svg_view_box.y + (svg_view_box.height / 2))); + const half_width = width / 2; const new_opening = { 'opening_id': window.crypto.randomUUID(), - 'x': Number((opening || {}).x || (svg_view_box.x + (svg_view_box.width / 2))), - 'y': Number((opening || {}).y || (svg_view_box.y + (svg_view_box.height / 2))), - 'width': width, + 'x': center_x, + 'y': center_y, 'height': height, - 'type': ['empty', 'door', 'window'].includes((opening || {}).type) ? opening.type : 'empty', + 'elevation': elevation, + 'points': (opening || {}).points && (opening || {}).points.length === 2 + ? beestat.clone(opening.points) + : [ + { + 'x': center_x - half_width, + 'y': center_y + }, + { + 'x': center_x + half_width, + 'y': center_y + } + ], + 'type': opening_type, + 'color': opening_type === 'door' ? String((opening || {}).color || default_color) : undefined, 'name': (opening || {}).name, 'editor_hidden': false, 'editor_locked': false @@ -1179,6 +1290,27 @@ beestat.component.floor_plan.prototype.add_opening_ = function(opening) { this.dispatchEvent('add_opening'); }; +/** + * Get opening width in floor-plan units from points. + * + * @param {object} opening + * + * @return {number} + */ +beestat.component.floor_plan.prototype.get_opening_width_ = function(opening) { + if ( + opening !== undefined && + opening.points !== undefined && + opening.points.length === 2 + ) { + const dx = Number(opening.points[1].x || 0) - Number(opening.points[0].x || 0); + const dy = Number(opening.points[1].y || 0) - Number(opening.points[0].y || 0); + return Math.max(0, Math.sqrt((dx * dx) + (dy * dy))); + } + + return Math.max(0, Number(opening.width || 0)); +}; + /** * Remove the currently active opening. */ @@ -1207,6 +1339,74 @@ beestat.component.floor_plan.prototype.remove_opening_ = function() { this.dispatchEvent('remove_opening'); }; +/** + * Add a new light source to the active floor. + * + * @param {object} light_source Optional light source to copy from. + */ +beestat.component.floor_plan.prototype.add_light_source_ = function(light_source) { + if (this.has_early_access_() !== true) { + return; + } + + this.save_buffer(); + + if (this.state_.active_group.light_sources === undefined) { + this.state_.active_group.light_sources = []; + } + + const svg_view_box = this.view_box_; + const new_light_source = { + 'light_source_id': window.crypto.randomUUID(), + 'x': Number((light_source || {}).x || (svg_view_box.x + (svg_view_box.width / 2))), + 'y': Number((light_source || {}).y || (svg_view_box.y + (svg_view_box.height / 2))), + 'elevation': Number((light_source || {}).elevation !== undefined ? light_source.elevation : 84), + 'intensity': ['dim', 'normal', 'bright'].includes((light_source || {}).intensity) + ? light_source.intensity + : 'normal', + 'temperature_k': Math.max(1000, Math.min(12000, Math.round(Number((light_source || {}).temperature_k || 4000)))), + 'name': (light_source || {}).name, + 'editor_hidden': false, + 'editor_locked': false + }; + + this.state_.active_group.light_sources.unshift(new_light_source); + new beestat.component.floor_plan_entity.light_source(this, this.state_) + .set_light_source(new_light_source) + .set_group(this.state_.active_group) + .set_active(true); + + this.dispatchEvent('add_light_source'); +}; + +/** + * Remove the currently active light source. + */ +beestat.component.floor_plan.prototype.remove_light_source_ = function() { + this.save_buffer(); + + if ( + this.state_.active_light_source_entity === undefined || + this.state_.active_group.light_sources === undefined + ) { + return; + } + + const self = this; + const index = this.state_.active_group.light_sources.findIndex(function(light_source) { + return light_source === self.state_.active_light_source_entity.get_light_source(); + }); + + if (index === -1) { + return; + } + + this.state_.active_light_source_entity.set_active(false); + this.state_.active_group.light_sources.splice(index, 1); + + this.dispatchEvent('remove_light_source'); +}; + /** * Add a new tree to the first floor. * @@ -1318,6 +1518,9 @@ beestat.component.floor_plan.prototype.clear_room_ = function() { if (this.state_.active_opening_entity !== undefined) { this.state_.active_opening_entity.set_active(false); } + if (this.state_.active_light_source_entity !== undefined) { + this.state_.active_light_source_entity.set_active(false); + } }; /** @@ -1581,6 +1784,7 @@ beestat.component.floor_plan.prototype.save_buffer = function(clear = true) { 'active_surface_entity': this.state_.active_surface_entity, 'active_tree_entity': this.state_.active_tree_entity, 'active_opening_entity': this.state_.active_opening_entity, + 'active_light_source_entity': this.state_.active_light_source_entity, 'active_group_id': this.state_.active_group.group_id }); @@ -1632,6 +1836,8 @@ beestat.component.floor_plan.prototype.undo_ = function() { this.state_.buffer[this.state_.buffer_pointer].active_tree_entity; this.state_.active_opening_entity = this.state_.buffer[this.state_.buffer_pointer].active_opening_entity; + this.state_.active_light_source_entity = + this.state_.buffer[this.state_.buffer_pointer].active_light_source_entity; // Restore any active group. this.state_.active_group_id = @@ -1683,6 +1889,8 @@ beestat.component.floor_plan.prototype.redo_ = function() { this.state_.buffer[this.state_.buffer_pointer].active_tree_entity; this.state_.active_opening_entity = this.state_.buffer[this.state_.buffer_pointer].active_opening_entity; + this.state_.active_light_source_entity = + this.state_.buffer[this.state_.buffer_pointer].active_light_source_entity; // Restore any active group. this.state_.active_group_id = diff --git a/js/component/floor_plan_entity/light_source.js b/js/component/floor_plan_entity/light_source.js new file mode 100644 index 0000000..ce34e82 --- /dev/null +++ b/js/component/floor_plan_entity/light_source.js @@ -0,0 +1,446 @@ +/** + * Floor plan light source. + */ +beestat.component.floor_plan_entity.light_source = function() { + this.enabled_ = true; + this.snap_lines_ = {}; + + beestat.component.floor_plan_entity.apply(this, arguments); +}; +beestat.extend(beestat.component.floor_plan_entity.light_source, beestat.component.floor_plan_entity); + +/** + * Decorate. + * + * @param {SVGGElement} parent + */ +beestat.component.floor_plan_entity.light_source.prototype.decorate_ = function(parent) { + this.decorate_circle_(parent); + + if (this.active_ === true && this.enabled_ === true) { + this.set_draggable_(true); + this.update_snap_points_(); + } +}; + +/** + * Draw the light-source circle. + * + * @param {SVGGElement} parent + */ +beestat.component.floor_plan_entity.light_source.prototype.decorate_circle_ = function(parent) { + const self = this; + + this.circle_ = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + this.circle_.setAttribute('r', 7); + this.circle_.style.strokeWidth = '2'; + parent.appendChild(this.circle_); + + const fill_color = '#f0cf59'; + + if (this.active_ === true) { + this.circle_.style.cursor = 'pointer'; + this.circle_.style.fillOpacity = '0.7'; + this.circle_.style.fill = fill_color; + this.circle_.style.stroke = '#ffffff'; + this.circle_.style.filter = 'brightness(1.15)'; + } else if (this.enabled_ === true) { + this.circle_.style.cursor = 'pointer'; + this.circle_.style.fillOpacity = '0.58'; + this.circle_.style.fill = fill_color; + this.circle_.style.stroke = beestat.style.color.gray.base; + this.circle_.style.filter = 'none'; + } else { + this.circle_.style.cursor = 'default'; + this.circle_.style.fillOpacity = '0.25'; + this.circle_.style.fill = beestat.style.color.gray.base; + this.circle_.style.stroke = beestat.style.color.gray.dark; + this.circle_.style.filter = 'none'; + } + + if (this.enabled_ === true) { + this.circle_.addEventListener('click', function(e) { + e.stopPropagation(); + self.set_active(true); + }); + } + + this.update_circle_(); +}; + +/** + * Update circle geometry. + */ +beestat.component.floor_plan_entity.light_source.prototype.update_circle_ = function() { + this.circle_.setAttribute('cx', 0); + this.circle_.setAttribute('cy', 0); +}; + +/** + * Set light source. + * + * @param {object} light_source + * + * @return {beestat.component.floor_plan_entity.light_source} + */ +beestat.component.floor_plan_entity.light_source.prototype.set_light_source = function(light_source) { + this.light_source_ = light_source; + + this.light_source_.light_source_id = this.light_source_.light_source_id || window.crypto.randomUUID(); + this.light_source_.x = Number(this.light_source_.x || 0); + this.light_source_.y = Number(this.light_source_.y || 0); + this.light_source_.elevation = Number(this.light_source_.elevation !== undefined ? this.light_source_.elevation : 84); + if (this.light_source_.name === undefined) { + this.light_source_.name = ''; + } + this.light_source_.intensity = ['dim', 'normal', 'bright'].includes(this.light_source_.intensity) + ? this.light_source_.intensity + : 'normal'; + this.light_source_.temperature_k = Math.max(1000, Math.min(12000, Math.round(Number(this.light_source_.temperature_k || 4000)))); + + this.x_ = this.light_source_.x; + this.y_ = this.light_source_.y; + + return this; +}; + +/** + * Set group. + * + * @param {object} group + * + * @return {beestat.component.floor_plan_entity.light_source} + */ +beestat.component.floor_plan_entity.light_source.prototype.set_group = function(group) { + this.group_ = group; + return this; +}; + +/** + * Set enabled. + * + * @param {boolean} enabled + * + * @return {beestat.component.floor_plan_entity.light_source} + */ +beestat.component.floor_plan_entity.light_source.prototype.set_enabled = function(enabled) { + this.enabled_ = enabled; + return this; +}; + +/** + * Get light source. + * + * @return {object} + */ +beestat.component.floor_plan_entity.light_source.prototype.get_light_source = function() { + return this.light_source_; +}; + +/** + * Set active state. + * + * @param {boolean} active + * + * @return {beestat.component.floor_plan_entity.light_source} + */ +beestat.component.floor_plan_entity.light_source.prototype.set_active = function(active) { + if (active === true && this.enabled_ !== true) { + return this; + } + + if (active !== this.active_) { + this.active_ = active; + + if (this.active_ === true) { + if ( + this.state_.active_light_source_entity !== undefined && + this.state_.active_light_source_entity.get_light_source().light_source_id !== this.light_source_.light_source_id + ) { + this.state_.active_light_source_entity.set_active(false); + } + + if (this.state_.active_point_entity !== undefined) { + this.state_.active_point_entity.set_active(false); + } + if (this.state_.active_wall_entity !== undefined) { + this.state_.active_wall_entity.set_active(false); + } + if (this.state_.active_room_entity !== undefined) { + this.state_.active_room_entity.set_active(false); + } + if (this.state_.active_surface_entity !== undefined) { + this.state_.active_surface_entity.set_active(false); + } + if (this.state_.active_opening_entity !== undefined) { + this.state_.active_opening_entity.set_active(false); + } + if (this.state_.active_tree_entity !== undefined) { + this.state_.active_tree_entity.set_active(false); + } + + this.state_.active_light_source_entity = this; + this.dispatchEvent('activate'); + this.update_snap_points_(); + this.bring_to_front_(); + } else { + delete this.state_.active_light_source_entity; + this.clear_snap_lines_(); + this.dispatchEvent('inactivate'); + } + + if (this.rendered_ === true) { + this.rerender(); + } + } + + return this; +}; + +/** + * Set position and clamp to grid. + * + * @param {number} x + * @param {number} y + * @param {string} event + * + * @return {beestat.component.floor_plan_entity.light_source} + */ +beestat.component.floor_plan_entity.light_source.prototype.set_xy = function(x, y, event = 'lesser_update') { + if (event === 'update') { + this.floor_plan_.save_buffer(); + } + + const half_grid = this.floor_plan_.get_grid_pixels() / 2; + let clamped_x = Math.round(Number(x || 0)); + let clamped_y = Math.round(Number(y || 0)); + clamped_x = Math.min(clamped_x, half_grid); + clamped_x = Math.max(clamped_x, -half_grid); + clamped_y = Math.min(clamped_y, half_grid); + clamped_y = Math.max(clamped_y, -half_grid); + + this.light_source_.x = clamped_x; + this.light_source_.y = clamped_y; + + this.dispatchEvent(event); + + return beestat.component.floor_plan_entity.prototype.set_xy.apply(this, [clamped_x, clamped_y]); +}; + +/** + * Drag start. + */ +beestat.component.floor_plan_entity.light_source.prototype.after_mousedown_handler_ = function() { + this.drag_start_entity_ = { + 'x': this.light_source_.x, + 'y': this.light_source_.y + }; +}; + +/** + * Drag move with snap-point behavior. + * + * @param {Event} e + */ +beestat.component.floor_plan_entity.light_source.prototype.after_mousemove_handler_ = function(e) { + if (this.drag_start_entity_ === undefined) { + return; + } + + const snap_distance = 6; + let desired_x = this.drag_start_entity_.x + (((e.clientX || e.touches[0].clientX) - this.drag_start_mouse_.x) * this.floor_plan_.get_scale()); + let desired_y = this.drag_start_entity_.y + (((e.clientY || e.touches[0].clientY) - this.drag_start_mouse_.y) * this.floor_plan_.get_scale()); + + if (this.state_.snapping === true) { + let best_x; + let best_x_distance = Number.POSITIVE_INFINITY; + let best_y; + let best_y_distance = Number.POSITIVE_INFINITY; + + for (let i = 0; i < this.get_snap_x().length; i++) { + const candidate_x = this.get_snap_x()[i]; + const distance_x = Math.abs(candidate_x - desired_x); + if (distance_x <= snap_distance && distance_x < best_x_distance) { + best_x = candidate_x; + best_x_distance = distance_x; + } + } + for (let i = 0; i < this.get_snap_y().length; i++) { + const candidate_y = this.get_snap_y()[i]; + const distance_y = Math.abs(candidate_y - desired_y); + if (distance_y <= snap_distance && distance_y < best_y_distance) { + best_y = candidate_y; + best_y_distance = distance_y; + } + } + + if (best_x !== undefined) { + desired_x = best_x; + } + if (best_y !== undefined) { + desired_y = best_y; + } + + this.update_snap_lines_(); + } else { + this.clear_snap_lines_(); + } + + this.set_xy(desired_x, desired_y); +}; + +/** + * Drag stop. + */ +beestat.component.floor_plan_entity.light_source.prototype.after_mouseup_handler_ = function() { + if (this.dragged_ === true) { + this.clear_snap_lines_(); + this.update_snap_points_(); + } +}; + +/** + * Pre-generate snap points. + */ +beestat.component.floor_plan_entity.light_source.prototype.update_snap_points_ = function() { + const self = this; + const snap_x = {}; + const snap_y = {}; + + const append_shapes = function(shapes, skip_self_light_source) { + if (Array.isArray(shapes) !== true) { + return; + } + + shapes.forEach(function(shape) { + if (shape.editor_hidden === true) { + return; + } + + if ( + skip_self_light_source === true && + shape.light_source_id !== undefined && + self.light_source_ !== undefined && + self.light_source_.light_source_id === shape.light_source_id + ) { + return; + } + + if (Array.isArray(shape.points) === true) { + shape.points.forEach(function(point) { + const is_opening = shape.opening_id !== undefined; + const absolute_x = is_opening + ? Number(point.x || 0) + : Number(point.x || 0) + Number(shape.x || 0); + const absolute_y = is_opening + ? Number(point.y || 0) + : Number(point.y || 0) + Number(shape.y || 0); + snap_x[absolute_x] = true; + snap_y[absolute_y] = true; + }); + } else { + snap_x[Number(shape.x || 0)] = true; + snap_y[Number(shape.y || 0)] = true; + } + }); + }; + + append_shapes(this.group_.rooms, false); + append_shapes(this.group_.surfaces, false); + append_shapes(this.group_.openings, false); + append_shapes(this.group_.light_sources, true); + + const group_below = this.floor_plan_.get_group_below(this.group_); + if (group_below !== undefined) { + append_shapes(group_below.rooms, false); + append_shapes(group_below.surfaces, false); + append_shapes(group_below.openings, false); + append_shapes(group_below.light_sources, false); + } + + this.snap_x_ = Object.keys(snap_x).map(function(key) { + return Number(key); + }); + this.snap_y_ = Object.keys(snap_y).map(function(key) { + return Number(key); + }); +}; + +/** + * Get snap x values. + * + * @return {number[]} + */ +beestat.component.floor_plan_entity.light_source.prototype.get_snap_x = function() { + return this.snap_x_ || []; +}; + +/** + * Get snap y values. + * + * @return {number[]} + */ +beestat.component.floor_plan_entity.light_source.prototype.get_snap_y = function() { + return this.snap_y_ || []; +}; + +/** + * Update snap lines. + */ +beestat.component.floor_plan_entity.light_source.prototype.update_snap_lines_ = function() { + const point_x = this.light_source_.x; + if (this.get_snap_x().includes(point_x) === true) { + if (this.snap_lines_.x === undefined) { + this.snap_lines_.x = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + this.snap_lines_.x.style.strokeDasharray = '7, 3'; + this.snap_lines_.x.style.stroke = beestat.style.color.yellow.base; + this.snap_lines_.x.setAttribute('y1', this.floor_plan_.get_grid_pixels() / -2); + this.snap_lines_.x.setAttribute('y2', this.floor_plan_.get_grid_pixels() / 2); + this.floor_plan_.get_g().appendChild(this.snap_lines_.x); + } + this.snap_lines_.x.setAttribute('x1', point_x); + this.snap_lines_.x.setAttribute('x2', point_x); + } else if (this.snap_lines_.x !== undefined) { + if (this.snap_lines_.x.parentNode !== undefined && this.snap_lines_.x.parentNode !== null) { + this.snap_lines_.x.parentNode.removeChild(this.snap_lines_.x); + } + delete this.snap_lines_.x; + } + + const point_y = this.light_source_.y; + if (this.get_snap_y().includes(point_y) === true) { + if (this.snap_lines_.y === undefined) { + this.snap_lines_.y = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + this.snap_lines_.y.style.strokeDasharray = '7, 3'; + this.snap_lines_.y.style.stroke = beestat.style.color.yellow.base; + this.snap_lines_.y.setAttribute('x1', this.floor_plan_.get_grid_pixels() / -2); + this.snap_lines_.y.setAttribute('x2', this.floor_plan_.get_grid_pixels() / 2); + this.floor_plan_.get_g().appendChild(this.snap_lines_.y); + } + this.snap_lines_.y.setAttribute('y1', point_y); + this.snap_lines_.y.setAttribute('y2', point_y); + } else if (this.snap_lines_.y !== undefined) { + if (this.snap_lines_.y.parentNode !== undefined && this.snap_lines_.y.parentNode !== null) { + this.snap_lines_.y.parentNode.removeChild(this.snap_lines_.y); + } + delete this.snap_lines_.y; + } +}; + +/** + * Clear snap lines. + */ +beestat.component.floor_plan_entity.light_source.prototype.clear_snap_lines_ = function() { + if (this.snap_lines_.x !== undefined) { + if (this.snap_lines_.x.parentNode !== undefined && this.snap_lines_.x.parentNode !== null) { + this.snap_lines_.x.parentNode.removeChild(this.snap_lines_.x); + } + delete this.snap_lines_.x; + } + if (this.snap_lines_.y !== undefined) { + if (this.snap_lines_.y.parentNode !== undefined && this.snap_lines_.y.parentNode !== null) { + this.snap_lines_.y.parentNode.removeChild(this.snap_lines_.y); + } + delete this.snap_lines_.y; + } +}; diff --git a/js/component/floor_plan_entity/opening.js b/js/component/floor_plan_entity/opening.js index e996395..d9abe2d 100644 --- a/js/component/floor_plan_entity/opening.js +++ b/js/component/floor_plan_entity/opening.js @@ -1,9 +1,14 @@ /** - * Floor plan opening (empty, door, window). + * Floor plan opening (empty, door, window) represented as a line segment with + * two draggable endpoints. */ beestat.component.floor_plan_entity.opening = function() { this.enabled_ = true; - this.resize_mode_ = null; + this.point_entities_ = []; + this.snap_lines_ = { + 'x': {}, + 'y': {} + }; beestat.component.floor_plan_entity.apply(this, arguments); }; @@ -17,7 +22,12 @@ beestat.extend(beestat.component.floor_plan_entity.opening, beestat.component.fl beestat.component.floor_plan_entity.opening.prototype.decorate_ = function(parent) { this.decorate_opening_(parent); - if (this.enabled_ === true) { + if (this.active_ === true) { + this.decorate_points_(parent); + this.update_snap_points_(); + } + + if (this.enabled_ === true && this.active_ === true) { this.set_draggable_(true); } }; @@ -31,35 +41,27 @@ beestat.component.floor_plan_entity.opening.prototype.decorate_opening_ = functi const self = this; this.path_ = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + this.path_id_ = String(Math.random()); + this.path_.setAttribute('id', this.path_id_); this.path_.style.fill = 'none'; this.path_.style.strokeLinecap = 'round'; - this.path_.style.cursor = this.enabled_ === true ? 'move' : 'default'; + this.path_.style.cursor = this.enabled_ === true ? 'pointer' : 'default'; parent.appendChild(this.path_); - this.left_handle_ = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - this.right_handle_ = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - [this.left_handle_, this.right_handle_].forEach(function(handle, index) { - handle.setAttribute('width', '8'); - handle.setAttribute('height', '8'); - handle.setAttribute('rx', '1'); - handle.setAttribute('ry', '1'); - handle.style.cursor = 'ew-resize'; - handle.style.strokeWidth = '1'; - parent.appendChild(handle); + this.text_ = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + this.text_.style.fontFamily = 'Montserrat'; + this.text_.style.fontWeight = '300'; + this.text_.style.fontSize = '11px'; + this.text_.style.fill = '#ffffff'; + this.text_.style.textAnchor = 'middle'; + this.text_.style.letterSpacing = '-0.5px'; + this.text_.setAttribute('dy', '1.1em'); - if (self.enabled_ === true) { - handle.addEventListener('mousedown', function(e) { - e.stopPropagation(); - self.resize_mode_ = index === 0 ? 'left' : 'right'; - self.mousedown_handler_(e); - }); - handle.addEventListener('touchstart', function(e) { - e.stopPropagation(); - self.resize_mode_ = index === 0 ? 'left' : 'right'; - self.mousedown_handler_(e); - }); - } - }); + this.text_path_ = document.createElementNS('http://www.w3.org/2000/svg', 'textPath'); + this.text_path_.setAttribute('href', '#' + this.path_id_); + this.text_path_.setAttribute('startOffset', '50%'); + this.text_.appendChild(this.text_path_); + parent.appendChild(this.text_); if (this.enabled_ === true) { this.path_.addEventListener('click', function(e) { @@ -73,34 +75,168 @@ beestat.component.floor_plan_entity.opening.prototype.decorate_opening_ = functi } this.update(); - this.apply_transform_(); }; /** * Update visuals. */ beestat.component.floor_plan_entity.opening.prototype.update = function() { - const width = Math.max(12, Number(this.opening_.width || 0)); - const half_width = width / 2; + if ( + this.opening_ === undefined || + this.opening_.points === undefined || + Array.isArray(this.opening_.points) !== true || + this.opening_.points.length < 2 + ) { + return; + } - this.path_.setAttribute('d', 'M' + (-half_width) + ',0 L' + half_width + ',0'); + this.opening_.width = Math.round(this.get_opening_width_()); + const center = this.get_center_(); + this.opening_.x = Math.round(center.x); + this.opening_.y = Math.round(center.y); + + if ( + this.path_ === undefined || + this.text_ === undefined || + this.text_path_ === undefined + ) { + return; + } + + this.update_line_(); + this.update_text_(); + this.update_points_(); +}; + +/** + * Add endpoint drag points. + * + * @param {SVGGElement} parent + */ +beestat.component.floor_plan_entity.opening.prototype.decorate_points_ = function(parent) { + const self = this; + + this.opening_.points.forEach(function(point) { + const point_entity = new beestat.component.floor_plan_entity.point(self.floor_plan_, self.state_) + .set_room(self) + .set_point(point) + .render(parent); + + point_entity.addEventListener('lesser_update', function() { + self.update(); + }); + + point_entity.addEventListener('update', function() { + self.update(); + self.update_snap_points_(); + self.dispatchEvent('update'); + }); + + point_entity.addEventListener('mousedown', function() { + point_entity.set_active(true); + }); + point_entity.addEventListener('touchstart', function() { + point_entity.set_active(true); + }); + + point_entity.addEventListener('activate', function() { + self.floor_plan_.update_toolbar(); + }); + + if ( + self.state_.active_point_entity !== undefined && + self.state_.active_point_entity.get_point() === point + ) { + point_entity.set_active(true); + } + + self.point_entities_.push(point_entity); + }); +}; + +/** + * Update endpoint drag points. + */ +beestat.component.floor_plan_entity.opening.prototype.update_points_ = function() { + this.point_entities_.forEach(function(point_entity) { + point_entity.update(); + }); +}; + +/** + * Update line path. + */ +beestat.component.floor_plan_entity.opening.prototype.update_line_ = function() { + const p1 = this.opening_.points[0]; + const p2 = this.opening_.points[1]; + this.path_.setAttribute('d', 'M' + p1.x + ',' + p1.y + ' L' + p2.x + ',' + p2.y); this.path_.style.stroke = this.get_opening_color_(); this.path_.style.strokeWidth = this.active_ === true ? '6' : '4'; this.path_.style.opacity = this.enabled_ === true ? (this.active_ === true ? '0.95' : '0.7') : '0.3'; +}; - const handles_visible = this.active_ === true && this.enabled_ === true; - const handle_fill = this.get_opening_color_(); - [this.left_handle_, this.right_handle_].forEach(function(handle) { - handle.style.visibility = handles_visible === true ? 'visible' : 'hidden'; - handle.style.fill = handle_fill; - handle.style.stroke = '#ffffff'; - }); - this.left_handle_.setAttribute('x', String(-half_width - 4)); - this.left_handle_.setAttribute('y', '-4'); - this.right_handle_.setAttribute('x', String(half_width - 4)); - this.right_handle_.setAttribute('y', '-4'); +/** + * Update length text. + */ +beestat.component.floor_plan_entity.opening.prototype.update_text_ = function() { + if (this.active_ !== true) { + this.text_.style.display = 'none'; + this.text_path_.textContent = ''; + return; + } - this.set_xy(this.opening_.x, this.opening_.y); + this.text_.style.display = 'block'; + const length = this.get_opening_width_(); + + if (length < 24) { + this.text_.style.fontSize = '6px'; + } else if (length < 48) { + this.text_.style.fontSize = '8px'; + } else { + this.text_.style.fontSize = '11px'; + } + + let length_string; + if (beestat.setting('units.distance') === 'ft') { + const length_feet = Math.floor(length / 12); + const length_inches = Math.round(length % 12); + length_string = length_feet + '\'' + ' ' + length_inches + '"'; + } else { + length_string = beestat.distance({ + 'distance': length, + 'units': true, + 'round': 2 + }); + } + + this.text_path_.textContent = length_string; +}; + +/** + * Get opening width. + * + * @return {number} + */ +beestat.component.floor_plan_entity.opening.prototype.get_opening_width_ = function() { + const p1 = this.opening_.points[0]; + const p2 = this.opening_.points[1]; + const dx = Number(p2.x || 0) - Number(p1.x || 0); + const dy = Number(p2.y || 0) - Number(p1.y || 0); + return Math.sqrt((dx * dx) + (dy * dy)); +}; + +/** + * Get line center. + * + * @return {{x:number,y:number}} + */ +beestat.component.floor_plan_entity.opening.prototype.get_center_ = function() { + const p1 = this.opening_.points[0]; + const p2 = this.opening_.points[1]; + return { + 'x': (Number(p1.x || 0) + Number(p2.x || 0)) / 2, + 'y': (Number(p1.y || 0) + Number(p2.y || 0)) / 2 + }; }; /** @@ -108,9 +244,14 @@ beestat.component.floor_plan_entity.opening.prototype.update = function() { */ beestat.component.floor_plan_entity.opening.prototype.after_mousedown_handler_ = function() { this.drag_start_entity_ = { - 'x': this.opening_.x || 0, - 'y': this.opening_.y || 0, - 'width': this.opening_.width || 0 + 'p1': { + 'x': Number(this.opening_.points[0].x || 0), + 'y': Number(this.opening_.points[0].y || 0) + }, + 'p2': { + 'x': Number(this.opening_.points[1].x || 0), + 'y': Number(this.opening_.points[1].y || 0) + } }; }; @@ -121,53 +262,255 @@ beestat.component.floor_plan_entity.opening.prototype.after_mousedown_handler_ = */ beestat.component.floor_plan_entity.opening.prototype.after_mousemove_handler_ = function(e) { const grid_half = this.floor_plan_.get_grid_pixels() / 2; - const min_width = 12; + const snap_distance = 6; - const dx = ((e.clientX || e.touches[0].clientX) - this.drag_start_mouse_.x) * this.floor_plan_.get_scale(); - const dy = ((e.clientY || e.touches[0].clientY) - this.drag_start_mouse_.y) * this.floor_plan_.get_scale(); + let desired_dx = ((e.clientX || e.touches[0].clientX) - this.drag_start_mouse_.x) * this.floor_plan_.get_scale(); + let desired_dy = ((e.clientY || e.touches[0].clientY) - this.drag_start_mouse_.y) * this.floor_plan_.get_scale(); - if (this.resize_mode_ === 'left' || this.resize_mode_ === 'right') { - const start_width = Math.max(min_width, Number(this.drag_start_entity_.width || 0)); - const start_left = this.drag_start_entity_.x - (start_width / 2); - const start_right = this.drag_start_entity_.x + (start_width / 2); + if (this.state_.snapping === true) { + const snap_x_values = this.get_snap_x(); + const snap_y_values = this.get_snap_y(); + const points = [ + { + 'x': this.drag_start_entity_.p1.x + desired_dx, + 'y': this.drag_start_entity_.p1.y + desired_dy + }, + { + 'x': this.drag_start_entity_.p2.x + desired_dx, + 'y': this.drag_start_entity_.p2.y + desired_dy + } + ]; - let next_left = start_left; - let next_right = start_right; + let best_snap_delta_x; + let best_snap_distance_x = Number.POSITIVE_INFINITY; + let best_snap_delta_y; + let best_snap_distance_y = Number.POSITIVE_INFINITY; - if (this.resize_mode_ === 'left') { - next_left = Math.min(start_left + dx, start_right - min_width); - next_left = Math.max(-grid_half, next_left); - } else { - next_right = Math.max(start_right + dx, start_left + min_width); - next_right = Math.min(grid_half, next_right); + for (let i = 0; i < points.length; i++) { + const point = points[i]; + + for (let j = 0; j < snap_x_values.length; j++) { + const snap_x = snap_x_values[j]; + const distance_x = Math.abs(snap_x - point.x); + if (distance_x <= snap_distance && distance_x < best_snap_distance_x) { + best_snap_distance_x = distance_x; + best_snap_delta_x = snap_x - point.x; + } + } + + for (let j = 0; j < snap_y_values.length; j++) { + const snap_y = snap_y_values[j]; + const distance_y = Math.abs(snap_y - point.y); + if (distance_y <= snap_distance && distance_y < best_snap_distance_y) { + best_snap_distance_y = distance_y; + best_snap_delta_y = snap_y - point.y; + } + } } - const next_width = Math.max(min_width, next_right - next_left); - const next_x = Math.max(-grid_half + (next_width / 2), Math.min(grid_half - (next_width / 2), (next_left + next_right) / 2)); + if (best_snap_delta_x !== undefined) { + desired_dx += best_snap_delta_x; + } + if (best_snap_delta_y !== undefined) { + desired_dy += best_snap_delta_y; + } - this.opening_.width = Math.round(next_width); - this.opening_.x = Math.round(next_x); - this.set_xy(this.opening_.x, this.opening_.y); - this.update(); - return; + this.update_snap_lines_(); + } else { + this.clear_snap_lines_(); } - const width = Math.max(min_width, Number(this.opening_.width || 0)); - const half_width = width / 2; - const next_x = this.drag_start_entity_.x + dx; - const next_y = this.drag_start_entity_.y + dy; + let applied_dx = desired_dx; + let applied_dy = desired_dy; - this.opening_.x = Math.round(Math.max(-grid_half + half_width, Math.min(grid_half - half_width, next_x))); - this.opening_.y = Math.round(Math.max(-grid_half, Math.min(grid_half, next_y))); - this.set_xy(this.opening_.x, this.opening_.y); + const min_dx = -grid_half - Math.min(this.drag_start_entity_.p1.x, this.drag_start_entity_.p2.x); + const max_dx = grid_half - Math.max(this.drag_start_entity_.p1.x, this.drag_start_entity_.p2.x); + const min_dy = -grid_half - Math.min(this.drag_start_entity_.p1.y, this.drag_start_entity_.p2.y); + const max_dy = grid_half - Math.max(this.drag_start_entity_.p1.y, this.drag_start_entity_.p2.y); + + applied_dx = Math.max(min_dx, Math.min(max_dx, applied_dx)); + applied_dy = Math.max(min_dy, Math.min(max_dy, applied_dy)); + + this.opening_.points[0].x = Math.round(this.drag_start_entity_.p1.x + applied_dx); + this.opening_.points[0].y = Math.round(this.drag_start_entity_.p1.y + applied_dy); + this.opening_.points[1].x = Math.round(this.drag_start_entity_.p2.x + applied_dx); + this.opening_.points[1].y = Math.round(this.drag_start_entity_.p2.y + applied_dy); + + this.update(); }; /** * Cleanup after mouseup. */ beestat.component.floor_plan_entity.opening.prototype.after_mouseup_handler_ = function() { - this.resize_mode_ = null; - this.update(); + if (this.dragged_ === true) { + this.clear_snap_lines_(); + this.update_snap_points_(); + } +}; + +/** + * Update snap lines to match the opening points. + */ +beestat.component.floor_plan_entity.opening.prototype.update_snap_lines_ = function() { + const self = this; + + let current_snap_x = {}; + this.opening_.points.forEach(function(point) { + current_snap_x[point.x] = true; + }); + + for (let x in this.snap_lines_.x) { + if (current_snap_x[x] === undefined) { + this.snap_lines_.x[x].parentNode.removeChild(this.snap_lines_.x[x]); + delete this.snap_lines_.x[x]; + } + } + + current_snap_x = Object.keys(current_snap_x).map(function(key) { + return Number(key); + }); + + const intersected_snap_x = this.get_snap_x().filter(function(x) { + return current_snap_x.includes(x) === true; + }); + + intersected_snap_x.forEach(function(x) { + if (self.snap_lines_.x[x] === undefined) { + self.snap_lines_.x[x] = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + self.snap_lines_.x[x].style.strokeDasharray = '7, 3'; + self.snap_lines_.x[x].style.stroke = beestat.style.color.yellow.base; + self.snap_lines_.x[x].setAttribute('x1', x); + self.snap_lines_.x[x].setAttribute('x2', x); + self.snap_lines_.x[x].setAttribute('y1', self.floor_plan_.get_grid_pixels() / -2); + self.snap_lines_.x[x].setAttribute('y2', self.floor_plan_.get_grid_pixels() / 2); + self.floor_plan_.get_g().appendChild(self.snap_lines_.x[x]); + } + }); + + let current_snap_y = {}; + this.opening_.points.forEach(function(point) { + current_snap_y[point.y] = true; + }); + + for (let y in this.snap_lines_.y) { + if (current_snap_y[y] === undefined) { + this.snap_lines_.y[y].parentNode.removeChild(this.snap_lines_.y[y]); + delete this.snap_lines_.y[y]; + } + } + + current_snap_y = Object.keys(current_snap_y).map(function(key) { + return Number(key); + }); + + const intersected_snap_y = this.get_snap_y().filter(function(y) { + return current_snap_y.includes(y) === true; + }); + + intersected_snap_y.forEach(function(y) { + if (self.snap_lines_.y[y] === undefined) { + self.snap_lines_.y[y] = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + self.snap_lines_.y[y].style.strokeDasharray = '7, 3'; + self.snap_lines_.y[y].style.stroke = beestat.style.color.yellow.base; + self.snap_lines_.y[y].setAttribute('y1', y); + self.snap_lines_.y[y].setAttribute('y2', y); + self.snap_lines_.y[y].setAttribute('x1', self.floor_plan_.get_grid_pixels() / -2); + self.snap_lines_.y[y].setAttribute('x2', self.floor_plan_.get_grid_pixels() / 2); + self.floor_plan_.get_g().appendChild(self.snap_lines_.y[y]); + } + }); +}; + +/** + * Clear all snap lines. + */ +beestat.component.floor_plan_entity.opening.prototype.clear_snap_lines_ = function() { + for (let x in this.snap_lines_.x) { + this.snap_lines_.x[x].parentNode.removeChild(this.snap_lines_.x[x]); + delete this.snap_lines_.x[x]; + } + for (let y in this.snap_lines_.y) { + this.snap_lines_.y[y].parentNode.removeChild(this.snap_lines_.y[y]); + delete this.snap_lines_.y[y]; + } +}; + +/** + * Pre-generate a list of snappable x/y values. + */ +beestat.component.floor_plan_entity.opening.prototype.update_snap_points_ = function() { + const self = this; + const snap_x = {}; + const snap_y = {}; + + const append_shapes = function(shapes, skip_self_opening) { + if (Array.isArray(shapes) !== true) { + return; + } + + shapes.forEach(function(shape) { + if (shape.editor_hidden === true || Array.isArray(shape.points) !== true) { + return; + } + if ( + skip_self_opening === true && + self.opening_ !== undefined && + shape.opening_id !== undefined && + self.opening_.opening_id !== undefined && + shape.opening_id === self.opening_.opening_id + ) { + return; + } + shape.points.forEach(function(point) { + const is_opening = shape.opening_id !== undefined; + const absolute_x = is_opening + ? Number(point.x || 0) + : Number(point.x || 0) + Number(shape.x || 0); + const absolute_y = is_opening + ? Number(point.y || 0) + : Number(point.y || 0) + Number(shape.y || 0); + snap_x[absolute_x] = true; + snap_y[absolute_y] = true; + }); + }); + }; + + append_shapes(this.group_.rooms, false); + append_shapes(this.group_.surfaces, false); + append_shapes(this.group_.openings, true); + + const group_below = this.floor_plan_.get_group_below(this.group_); + if (group_below !== undefined) { + append_shapes(group_below.rooms, false); + append_shapes(group_below.surfaces, false); + append_shapes(group_below.openings, false); + } + + this.snap_x_ = Object.keys(snap_x).map(function(key) { + return Number(key); + }); + this.snap_y_ = Object.keys(snap_y).map(function(key) { + return Number(key); + }); +}; + +/** + * Get snap x values. + * + * @return {number[]} + */ +beestat.component.floor_plan_entity.opening.prototype.get_snap_x = function() { + return this.snap_x_ || []; +}; + +/** + * Get snap y values. + * + * @return {number[]} + */ +beestat.component.floor_plan_entity.opening.prototype.get_snap_y = function() { + return this.snap_y_ || []; }; /** @@ -186,14 +529,36 @@ beestat.component.floor_plan_entity.opening.prototype.set_opening = function(ope if (this.opening_.type === undefined) { this.opening_.type = 'empty'; } - if (this.opening_.width === undefined) { - this.opening_.width = 36; - } if (this.opening_.height === undefined) { - this.opening_.height = 80; + this.opening_.height = (this.opening_.type === 'window' || this.opening_.type === 'glass') ? 42 : 78; + } + if (this.opening_.elevation === undefined) { + this.opening_.elevation = (this.opening_.type === 'window' || this.opening_.type === 'glass') ? 36 : 0; } - this.set_xy(this.opening_.x || 0, this.opening_.y || 0); + const default_width = (this.opening_.type === 'window' || this.opening_.type === 'glass') ? 48 : 36; + const width = Math.max(12, Number(this.opening_.width || default_width)); + const center_x = Number(this.opening_.x || 0); + const center_y = Number(this.opening_.y || 0); + + if ( + this.opening_.points === undefined || + Array.isArray(this.opening_.points) !== true || + this.opening_.points.length !== 2 + ) { + this.opening_.points = [ + { + 'x': Math.round(center_x - (width / 2)), + 'y': Math.round(center_y) + }, + { + 'x': Math.round(center_x + (width / 2)), + 'y': Math.round(center_y) + } + ]; + } + + this.update(); return this; }; @@ -231,7 +596,7 @@ beestat.component.floor_plan_entity.opening.prototype.set_enabled = function(ena }; /** - * Set x/y with clamping. + * Set center x/y for this opening by translating both points. * * @param {number} x * @param {number} y @@ -244,23 +609,28 @@ beestat.component.floor_plan_entity.opening.prototype.set_xy = function(x, y, ev this.floor_plan_.save_buffer(); } + const center = this.get_center_(); + const target_x = x === null ? center.x : Number(x || 0); + const target_y = y === null ? center.y : Number(y || 0); + const grid_half = this.floor_plan_.get_grid_pixels() / 2; - const half_width = Math.max(12, Number(this.opening_.width || 0)) / 2; + const min_point_x = Math.min(Number(this.opening_.points[0].x || 0), Number(this.opening_.points[1].x || 0)); + const max_point_x = Math.max(Number(this.opening_.points[0].x || 0), Number(this.opening_.points[1].x || 0)); + const min_point_y = Math.min(Number(this.opening_.points[0].y || 0), Number(this.opening_.points[1].y || 0)); + const max_point_y = Math.max(Number(this.opening_.points[0].y || 0), Number(this.opening_.points[1].y || 0)); - const clamped_x = Math.max(-grid_half + half_width, Math.min(grid_half - half_width, Number(x || 0))); - const clamped_y = Math.max(-grid_half, Math.min(grid_half, Number(y || 0))); + let dx = target_x - center.x; + let dy = target_y - center.y; - this.opening_.x = Math.round(clamped_x); - this.opening_.y = Math.round(clamped_y); + dx = Math.max(-grid_half - min_point_x, Math.min(grid_half - max_point_x, dx)); + dy = Math.max(-grid_half - min_point_y, Math.min(grid_half - max_point_y, dy)); - beestat.component.floor_plan_entity.prototype.set_xy.apply( - this, - [ - this.opening_.x, - this.opening_.y - ] - ); + this.opening_.points[0].x = Math.round(Number(this.opening_.points[0].x || 0) + dx); + this.opening_.points[0].y = Math.round(Number(this.opening_.points[0].y || 0) + dy); + this.opening_.points[1].x = Math.round(Number(this.opening_.points[1].x || 0) + dx); + this.opening_.points[1].y = Math.round(Number(this.opening_.points[1].y || 0) + dy); + this.update(); this.dispatchEvent(event); return this; }; @@ -280,18 +650,23 @@ beestat.component.floor_plan_entity.opening.prototype.set_active = function(acti if (active === true) { if (this.state_.active_point_entity !== undefined) { this.state_.active_point_entity.set_active(false); + this.floor_plan_.update_toolbar(); } if (this.state_.active_wall_entity !== undefined) { this.state_.active_wall_entity.set_active(false); + this.floor_plan_.update_toolbar(); } if (this.state_.active_tree_entity !== undefined) { this.state_.active_tree_entity.set_active(false); + this.floor_plan_.update_toolbar(); } if (this.state_.active_surface_entity !== undefined) { this.state_.active_surface_entity.set_active(false); + this.floor_plan_.update_toolbar(); } if (this.state_.active_room_entity !== undefined) { this.state_.active_room_entity.set_active(false); + this.floor_plan_.update_toolbar(); } } @@ -308,9 +683,16 @@ beestat.component.floor_plan_entity.opening.prototype.set_active = function(acti this.state_.active_opening_entity = this; this.dispatchEvent('activate'); + this.update_snap_points_(); this.bring_to_front_(); } else { delete this.state_.active_opening_entity; + this.clear_snap_lines_(); + + if (this.state_.active_point_entity !== undefined) { + this.state_.active_point_entity.set_active(false); + } + this.dispatchEvent('inactivate'); } @@ -322,6 +704,15 @@ beestat.component.floor_plan_entity.opening.prototype.set_active = function(acti return this; }; +/** + * Get shape-like room proxy used by point entity logic. + * + * @return {object} + */ +beestat.component.floor_plan_entity.opening.prototype.get_room = function() { + return this.opening_; +}; + /** * Get color by opening type. * @@ -330,8 +721,10 @@ beestat.component.floor_plan_entity.opening.prototype.set_active = function(acti beestat.component.floor_plan_entity.opening.prototype.get_opening_color_ = function() { switch (this.opening_.type) { case 'door': + case 'garage': return beestat.style.color.green.base; case 'window': + case 'glass': return beestat.style.color.lightblue.light; case 'empty': default: diff --git a/js/component/floor_plan_entity/room.js b/js/component/floor_plan_entity/room.js index d82bd61..04d1c95 100644 --- a/js/component/floor_plan_entity/room.js +++ b/js/component/floor_plan_entity/room.js @@ -318,6 +318,16 @@ beestat.component.floor_plan_entity.room.prototype.update_snap_points_ = functio snap_y[point.y + room.y] = true; }); }); + (this.group_.openings || []).forEach(function(opening) { + if (opening.editor_hidden === true || Array.isArray(opening.points) !== true) { + return; + } + opening.points.forEach(function(point) { + // Opening points are stored in absolute editor coordinates. + snap_x[point.x] = true; + snap_y[point.y] = true; + }); + }); // Snap to rooms in the group under this one. const group_below = this.floor_plan_.get_group_below(this.group_); @@ -331,6 +341,16 @@ beestat.component.floor_plan_entity.room.prototype.update_snap_points_ = functio snap_y[point.y + room.y] = true; }); }); + (group_below.openings || []).forEach(function(opening) { + if (opening.editor_hidden === true || Array.isArray(opening.points) !== true) { + return; + } + opening.points.forEach(function(point) { + // Opening points are stored in absolute editor coordinates. + snap_x[point.x] = true; + snap_y[point.y] = true; + }); + }); } this.snap_x_ = Object.keys(snap_x).map(function(key) { diff --git a/js/component/floor_plan_entity/surface.js b/js/component/floor_plan_entity/surface.js index 89c22ba..80d9392 100644 --- a/js/component/floor_plan_entity/surface.js +++ b/js/component/floor_plan_entity/surface.js @@ -174,23 +174,28 @@ beestat.component.floor_plan_entity.surface.prototype.update_snap_points_ = func } shapes.forEach(function(shape) { - if (shape.editor_hidden === true) { + if (shape.editor_hidden === true || Array.isArray(shape.points) !== true) { return; } shape.points.forEach(function(point) { - snap_x[point.x + shape.x] = true; - snap_y[point.y + shape.y] = true; + const is_opening = shape.opening_id !== undefined; + const absolute_x = is_opening ? Number(point.x || 0) : Number(point.x || 0) + Number(shape.x || 0); + const absolute_y = is_opening ? Number(point.y || 0) : Number(point.y || 0) + Number(shape.y || 0); + snap_x[absolute_x] = true; + snap_y[absolute_y] = true; }); }); }; append_shapes(this.group_.rooms); append_shapes(this.group_.surfaces); + append_shapes(this.group_.openings); const group_below = this.floor_plan_.get_group_below(this.group_); if (group_below !== undefined) { append_shapes(group_below.rooms); append_shapes(group_below.surfaces); + append_shapes(group_below.openings); } this.snap_x_ = Object.keys(snap_x).map(function(key) { diff --git a/js/component/floor_plan_layers_sidebar.js b/js/component/floor_plan_layers_sidebar.js index 4ac8c61..c11353c 100644 --- a/js/component/floor_plan_layers_sidebar.js +++ b/js/component/floor_plan_layers_sidebar.js @@ -96,6 +96,7 @@ beestat.component.floor_plan_layers_sidebar.prototype.decorate_ = function(paren sidebar_state.collapsed_types[group.group_id + '.trees'] = true; sidebar_state.collapsed_types[group.group_id + '.surfaces'] = true; sidebar_state.collapsed_types[group.group_id + '.openings'] = true; + sidebar_state.collapsed_types[group.group_id + '.light_sources'] = true; sidebar_state.collapsed_types[group.group_id + '.rooms'] = true; }); sidebar_state.initialized_collapsed = true; @@ -114,6 +115,9 @@ beestat.component.floor_plan_layers_sidebar.prototype.decorate_ = function(paren if (sidebar_state.collapsed_types[group.group_id + '.openings'] === undefined) { sidebar_state.collapsed_types[group.group_id + '.openings'] = true; } + if (sidebar_state.collapsed_types[group.group_id + '.light_sources'] === undefined) { + sidebar_state.collapsed_types[group.group_id + '.light_sources'] = true; + } if (sidebar_state.collapsed_types[group.group_id + '.rooms'] === undefined) { sidebar_state.collapsed_types[group.group_id + '.rooms'] = true; } @@ -127,6 +131,7 @@ beestat.component.floor_plan_layers_sidebar.prototype.decorate_ = function(paren .concat(group.trees || []) .concat(group.surfaces || []) .concat(group.openings || []) + .concat(group.light_sources || []) .concat(group.rooms || []); const has_group_objects = group_objects.length > 0; const group_all_hidden = has_group_objects === true && group_objects.every(function(object) { @@ -220,6 +225,9 @@ beestat.component.floor_plan_layers_sidebar.prototype.decorate_ = function(paren if ((group.openings || []).length > 0) { self.on_toggle_layer_visibility_(group, 'openings', group_all_hidden === true); } + if ((group.light_sources || []).length > 0) { + self.on_toggle_layer_visibility_(group, 'light_sources', group_all_hidden === true); + } if ((group.rooms || []).length > 0) { self.on_toggle_layer_visibility_(group, 'rooms', group_all_hidden === true); } @@ -257,6 +265,9 @@ beestat.component.floor_plan_layers_sidebar.prototype.decorate_ = function(paren if ((group.openings || []).length > 0) { self.on_toggle_layer_lock_(group, 'openings', !group_all_locked); } + if ((group.light_sources || []).length > 0) { + self.on_toggle_layer_lock_(group, 'light_sources', !group_all_locked); + } if ((group.rooms || []).length > 0) { self.on_toggle_layer_lock_(group, 'rooms', !group_all_locked); } @@ -321,6 +332,15 @@ beestat.component.floor_plan_layers_sidebar.prototype.decorate_ = function(paren scroll_to, scroll_to_row ); + scroll_to_row = self.decorate_group_type_( + group_panel, + group, + 'light_sources', + 'Light Source', + font_size_small, + scroll_to, + scroll_to_row + ); scroll_to_row = self.decorate_group_type_( group_panel, group, @@ -991,6 +1011,9 @@ beestat.component.floor_plan_layers_sidebar.prototype.get_type_icon_ = function( if (type === 'openings') { return 'window_closed_variant'; } + if (type === 'light_sources') { + return 'lightbulb_on'; + } return 'view_quilt'; }; @@ -1039,6 +1062,9 @@ beestat.component.floor_plan_layers_sidebar.prototype.get_object_id_ = function( if (type === 'openings') { return object.opening_id; } + if (type === 'light_sources') { + return object.light_source_id; + } return object.tree_id; }; @@ -1113,6 +1139,13 @@ beestat.component.floor_plan_layers_sidebar.prototype.is_active_row_ = function( ) { return true; } + if ( + type === 'light_sources' && + this.state_.active_light_source_entity !== undefined && + this.state_.active_light_source_entity.get_light_source().light_source_id === object_id + ) { + return true; + } return false; }; diff --git a/js/component/scene.js b/js/component/scene.js index 4806b42..dffd810 100644 --- a/js/component/scene.js +++ b/js/component/scene.js @@ -212,7 +212,21 @@ beestat.component.scene.sun_light_intensity = 0.6; * * @type {number} */ -beestat.component.scene.moon_light_intensity = 0.35; +beestat.component.scene.moon_light_intensity = 0.13125; + +/** + * Peak per-room interior light intensity used at night. + * + * @type {number} + */ +beestat.component.scene.interior_light_intensity = 0.9; + +/** + * Max number of interior point lights allowed to cast shadows. + * + * @type {number} + */ +beestat.component.scene.interior_light_shadow_max = 1; /** * Number of star sprites generated in the sky dome. @@ -509,9 +523,12 @@ beestat.component.scene.prototype.decorate_ = function(parent) { 'watcher': false, 'roof_edges': false, 'straight_skeleton': false, - 'openings': true, - 'opening_cutters': false + 'openings': false, + 'opening_cutters': false, + 'hide_tree_branches': false, + 'light_source_orbs': false }; + this.room_interaction_enabled_ = true; this.width_ = this.state_.scene_width || 800; this.height_ = 500; @@ -715,6 +732,20 @@ beestat.component.scene.prototype.add_raycaster_ = function() { */ beestat.component.scene.prototype.update_raycaster_ = function() { if (this.raycaster_ !== undefined) { + if (this.room_interaction_enabled_ !== true) { + 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_; + } + return; + } + this.raycaster_.setFromCamera(this.raycaster_pointer_, this.camera_); const intersects = this.raycaster_.intersectObject(this.scene_); @@ -736,7 +767,9 @@ beestat.component.scene.prototype.update_raycaster_ = function() { intersects[i].object.type === 'Mesh' && intersects[i].object.material !== undefined && intersects[i].object.material.emissive !== undefined && + intersects[i].object.userData.room !== undefined && intersects[i].object.userData.is_wall !== true && + intersects[i].object.userData.is_opening !== true && intersects[i].object.userData.is_surface !== true && intersects[i].object.userData.is_roof !== true && intersects[i].object.userData.is_environment !== true && @@ -1167,7 +1200,7 @@ beestat.component.scene.prototype.add_celestial_lights_ = function() { 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_geometry = new THREE.SphereGeometry(146, 24, 24); const sun_core_material = new THREE.MeshBasicMaterial({ 'color': 0xffffff, 'transparent': true, @@ -1189,7 +1222,7 @@ beestat.component.scene.prototype.add_celestial_lights_ = function() { }); 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_glow_sprite_.scale.set(1037, 1037, 1); this.sun_visual_group_.add(this.sun_glow_sprite_); if (this.debug_.sun_light_helper === true) { @@ -1244,7 +1277,7 @@ beestat.component.scene.prototype.add_celestial_lights_ = function() { }); 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_sprite_.scale.set(405, 405, 1); this.moon_visual_group_.add(this.moon_sprite_); if (this.debug_.moon_light_helper === true) { @@ -1438,6 +1471,14 @@ beestat.component.scene.prototype.update_celestial_lights_ = function(date, lati Math.min(1, (-sun_pos.altitude - 0.05) / 0.25) ); + const interior_night_factor = Math.max( + 0, + Math.min(1, (-sun_pos.altitude + 0.03) / 0.3) + ); + this.target_interior_light_intensity_ = + beestat.component.scene.interior_light_intensity * interior_night_factor; + this.target_light_source_intensity_multiplier_ = interior_night_factor; + // Moon const moon_pos = SunCalc.getMoonPosition(js_date, latitude, longitude); // Keep moon conversion consistent with the sun conversion. @@ -1512,6 +1553,18 @@ beestat.component.scene.prototype.update_celestial_light_intensities_ = function if (this.target_moon_intensity_ === undefined) { this.target_moon_intensity_ = 0; } + if (this.target_interior_light_intensity_ === undefined) { + const hour = this.date_ !== undefined ? Number(this.date_.format('H')) : 12; + this.target_interior_light_intensity_ = ( + (hour >= 19 || hour <= 5) + ? beestat.component.scene.interior_light_intensity + : 0 + ); + } + if (this.target_light_source_intensity_multiplier_ === undefined) { + const hour = this.date_ !== undefined ? Number(this.date_.format('H')) : 12; + this.target_light_source_intensity_multiplier_ = (hour >= 19 || hour <= 5) ? 1 : 0; + } // Lerp factor - lower = smoother but slower, higher = faster but jumpier const lerp_factor = 0.05; @@ -1522,6 +1575,19 @@ beestat.component.scene.prototype.update_celestial_light_intensities_ = function // Lerp moon intensity this.moon_light_.intensity += (this.target_moon_intensity_ - this.moon_light_.intensity) * lerp_factor; + if (this.interior_lights_ !== undefined) { + this.interior_lights_.forEach((light) => { + light.intensity += (this.target_interior_light_intensity_ - light.intensity) * lerp_factor; + }); + } + if (Array.isArray(this.light_sources_) === true) { + this.light_sources_.forEach((light) => { + const base_intensity = Number(light.userData.base_intensity || 0); + const target_intensity = base_intensity * this.target_light_source_intensity_multiplier_; + light.intensity += (target_intensity - 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) { @@ -2163,11 +2229,15 @@ beestat.component.scene.prototype.build_opening_cutter_mesh_ = function(group, o return null; } - const width = Math.max(12, Number(opening.width || 0)); - const height = Math.max(1, Number(opening.height || 0)); + const opening_line = this.get_opening_line_params_(opening); + const width = opening_line.width; + const height = Math.max(1, Number(opening.height || this.get_opening_default_height_(opening.type))); const wall_thickness = Number(beestat.component.scene.wall_thickness || 4); - const depth = Math.max(0.5, wall_thickness); - const elevation = Number(group.elevation || 0); + // Slightly oversize cutter depth so CSG fully clears wall thickness even when + // a snapped opening is numerically near-coplanar with one wall face. + const cutter_depth_padding = 4; + const depth = Math.max(0.5, wall_thickness + cutter_depth_padding); + const center_z = this.get_opening_center_z_(group, opening, height); if (this.csg_cutter_material_ === undefined) { this.csg_cutter_material_ = new THREE.MeshBasicMaterial({ @@ -2178,16 +2248,105 @@ beestat.component.scene.prototype.build_opening_cutter_mesh_ = function(group, o 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) + opening_line.center_x, + opening_line.center_y, + center_z ); + cutter.rotation.z = opening_line.rotation_radians; cutter.updateMatrix(); cutter.updateMatrixWorld(true); return cutter; }; +/** + * Get default opening width by type. + * + * @param {string} type + * + * @return {number} + */ +beestat.component.scene.prototype.get_opening_default_width_ = function(type) { + return (type === 'window' || type === 'glass') ? 48 : 36; +}; + +/** + * Get default opening height by type. + * + * @param {string} type + * + * @return {number} + */ +beestat.component.scene.prototype.get_opening_default_height_ = function(type) { + return (type === 'window' || type === 'glass') ? 42 : 78; +}; + +/** + * Get default opening elevation by type. + * + * @param {string} type + * + * @return {number} + */ +beestat.component.scene.prototype.get_opening_default_elevation_ = function(type) { + return (type === 'window' || type === 'glass') ? 36 : 0; +}; + +/** + * Resolve opening line parameters from endpoint data. + * + * @param {object} opening + * + * @return {{center_x:number, center_y:number, width:number, rotation_radians:number}} + */ +beestat.component.scene.prototype.get_opening_line_params_ = function(opening) { + if ( + opening.points !== undefined && + Array.isArray(opening.points) === true && + opening.points.length === 2 + ) { + const p1 = opening.points[0]; + const p2 = opening.points[1]; + const dx = Number(p2.x || 0) - Number(p1.x || 0); + const dy = Number(p2.y || 0) - Number(p1.y || 0); + return { + 'center_x': (Number(p1.x || 0) + Number(p2.x || 0)) / 2, + 'center_y': (Number(p1.y || 0) + Number(p2.y || 0)) / 2, + 'width': Math.max(12, Math.sqrt((dx * dx) + (dy * dy))), + 'rotation_radians': Math.atan2(dy, dx) + }; + } + + const width = Math.max(12, Number(opening.width || this.get_opening_default_width_(opening.type))); + return { + 'center_x': Number(opening.x || 0), + 'center_y': Number(opening.y || 0), + 'width': width, + 'rotation_radians': (Number(opening.rotation || 0) * Math.PI) / 180 + }; +}; + +/** + * Get the opening center Z. Opening elevation is measured from the bottom of + * the room reference plane, matching existing floor-plan behavior. + * + * @param {object} group The floor plan group. + * @param {object} opening The opening. + * @param {number} height The opening height. + * + * @return {number} + */ +beestat.component.scene.prototype.get_opening_center_z_ = function(group, opening, height) { + const group_elevation = Number(group.elevation || 0); + const floor_thickness = Number(beestat.component.scene.room_floor_thickness || 0); + const opening_elevation = Number( + opening.elevation !== undefined + ? opening.elevation + : this.get_opening_default_elevation_(opening.type) + ); + return -group_elevation - floor_thickness - opening_elevation - (height / 2); +}; + /** * Add a debug wireframe for an opening cutter. * @@ -2324,9 +2483,10 @@ beestat.component.scene.prototype.add_openings_debug_ = function(layer, group) { 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 opening_line = this.get_opening_line_params_(opening); + const width = opening_line.width; + const height = Math.max(1, Number(opening.height || this.get_opening_default_height_(opening.type))); + const center_z = this.get_opening_center_z_(group, opening, height); const geometry = new THREE.BoxGeometry( width, @@ -2342,13 +2502,359 @@ beestat.component.scene.prototype.add_openings_debug_ = function(layer, group) { }) ); - wireframe.position.x = Number(opening.x || 0); - wireframe.position.y = Number(opening.y || 0); - wireframe.position.z = -elevation - (height / 2); + wireframe.position.x = opening_line.center_x; + wireframe.position.y = opening_line.center_y; + wireframe.position.z = center_z; + wireframe.rotation.z = opening_line.rotation_radians; wireframe.layers.set(beestat.component.scene.layer_visible); layer.add(wireframe); - }); + }, this); +}; + +/** + * Add 3D opening fixtures. + * + * @param {THREE.Group} layer The layer to add fixtures to. + * @param {object} group The floor plan group. + */ +beestat.component.scene.prototype.add_opening_fixtures_ = function(layer, group) { + if (group.openings === undefined || group.openings.length === 0) { + return; + } + + const wall_thickness = Number(beestat.component.scene.wall_thickness || 4); + const frame_thickness = Math.max(1, wall_thickness * 0.3); + + if (this.opening_frame_material_ === undefined) { + this.opening_frame_material_ = new THREE.MeshStandardMaterial({ + 'color': 0xf5f7fb, + 'roughness': 0.92, + 'metalness': 0.0 + }); + } + if (this.window_pane_material_ === undefined) { + this.window_pane_material_ = new THREE.MeshPhysicalMaterial({ + 'color': 0xbfe6ff, + 'transparent': true, + 'opacity': 0.95, + 'roughness': 0.12, + 'metalness': 0.0, + 'reflectivity': 0.35, + 'clearcoat': 0.3, + 'clearcoatRoughness': 0.12, + 'transmission': 0.15 + }); + } + + group.openings.forEach(function(opening) { + if (opening.editor_hidden === true) { + return; + } + if (opening.type !== 'door' && opening.type !== 'window' && opening.type !== 'glass') { + return; + } + + const opening_line = this.get_opening_line_params_(opening); + const width = opening_line.width; + const height = Math.max(1, Number(opening.height || this.get_opening_default_height_(opening.type))); + const center_z = this.get_opening_center_z_(group, opening, height); + + const fixture_group = new THREE.Group(); + fixture_group.position.set( + opening_line.center_x, + opening_line.center_y, + center_z + ); + fixture_group.rotation.z = opening_line.rotation_radians; + + const side_geometry = new THREE.BoxGeometry(frame_thickness, wall_thickness, height); + const left_side = new THREE.Mesh(side_geometry, this.opening_frame_material_); + left_side.position.x = -(width / 2) + (frame_thickness / 2); + left_side.castShadow = true; + left_side.receiveShadow = true; + left_side.userData.is_opening = true; + fixture_group.add(left_side); + + const right_side = new THREE.Mesh(side_geometry, this.opening_frame_material_); + right_side.position.x = (width / 2) - (frame_thickness / 2); + right_side.castShadow = true; + right_side.receiveShadow = true; + right_side.userData.is_opening = true; + fixture_group.add(right_side); + + const top_geometry = new THREE.BoxGeometry(width, wall_thickness, frame_thickness); + const top_frame = new THREE.Mesh(top_geometry, this.opening_frame_material_); + top_frame.position.z = (height / 2) - (frame_thickness / 2); + top_frame.castShadow = true; + top_frame.receiveShadow = true; + top_frame.userData.is_opening = true; + fixture_group.add(top_frame); + + if (opening.type === 'window' || opening.type === 'glass') { + const bottom_frame = new THREE.Mesh(top_geometry, this.opening_frame_material_); + bottom_frame.position.z = -(height / 2) + (frame_thickness / 2); + bottom_frame.castShadow = true; + bottom_frame.receiveShadow = true; + bottom_frame.userData.is_opening = true; + fixture_group.add(bottom_frame); + + // Keep pane nearly flush to the inner frame; only leave a tiny inset to + // avoid edge z-fighting/flicker. + const pane_inset = 0.05; + const pane_width = Math.max(6, width - (frame_thickness * 2) - pane_inset); + const pane_height = Math.max(6, height - (frame_thickness * 2) - pane_inset); + const pane_depth = Math.max(0.25, wall_thickness * 0.18); + const pane = new THREE.Mesh( + new THREE.BoxGeometry(pane_width, pane_depth, pane_height), + this.window_pane_material_ + ); + pane.castShadow = false; + pane.receiveShadow = false; + pane.userData.is_opening = true; + fixture_group.add(pane); + + if (opening.type === 'window') { + const divider_thickness = Math.max(1.4, frame_thickness * 0.7); + const divider_overhang = Math.max(0.35, wall_thickness * 0.12); + const divider_depth = wall_thickness + (divider_overhang * 2); + const divider = new THREE.Mesh( + new THREE.BoxGeometry( + pane_width, + divider_depth, + divider_thickness + ), + this.opening_frame_material_ + ); + divider.position.z = 0; + // Keep centered so the mullion protrudes equally from front/back faces. + divider.position.y = 0; + divider.castShadow = true; + divider.receiveShadow = true; + divider.userData.is_opening = true; + fixture_group.add(divider); + } + } else if (opening.type === 'door') { + const bottom_frame = new THREE.Mesh(top_geometry, this.opening_frame_material_); + bottom_frame.position.z = -(height / 2) + (frame_thickness / 2); + bottom_frame.castShadow = true; + bottom_frame.receiveShadow = true; + bottom_frame.userData.is_opening = true; + fixture_group.add(bottom_frame); + + const door_clearance = 0.25; + const door_width = Math.max(10, width - (frame_thickness * 2) - door_clearance); + const door_height = Math.max(12, height - (frame_thickness * 2) - door_clearance); + const door_depth = Math.max(0.6, wall_thickness * 0.35); + const raw_door_color = String(opening.color || '#7a573b').toLowerCase(); + const door_color = ['#2d2d2d', '#3f3f3f'].includes(raw_door_color) + ? '#4a4a4a' + : raw_door_color; + const door_material = new THREE.MeshStandardMaterial({ + 'color': door_color, + 'roughness': 0.72, + 'metalness': 0.0 + }); + const door = new THREE.Mesh( + new THREE.BoxGeometry(door_width, door_depth, door_height), + door_material + ); + door.position.z = 0; + door.castShadow = true; + door.receiveShadow = true; + door.userData.is_opening = true; + fixture_group.add(door); + + } + + layer.add(fixture_group); + }, this); +}; + +/** + * Convert color temperature in Kelvin to RGB color. + * + * @param {number} temperature_k + * + * @return {THREE.Color} + */ +beestat.component.scene.prototype.get_light_color_from_temperature_ = function(temperature_k) { + const kelvin = Math.max(1000, Math.min(12000, Number(temperature_k || 4000))); + const temp = kelvin / 100; + + let red; + let green; + let blue; + + if (temp <= 66) { + red = 255; + green = 99.4708025861 * Math.log(temp) - 161.1195681661; + blue = temp <= 19 ? 0 : (138.5177312231 * Math.log(temp - 10) - 305.0447927307); + } else { + red = 329.698727446 * Math.pow(temp - 60, -0.1332047592); + green = 288.1221695283 * Math.pow(temp - 60, -0.0755148492); + blue = 255; + } + + const clamp_channel = function(value) { + return Math.max(0, Math.min(255, Number(value || 0))); + }; + + return new THREE.Color( + clamp_channel(red) / 255, + clamp_channel(green) / 255, + clamp_channel(blue) / 255 + ); +}; + +/** + * Add floor-plan light sources. + * + * @param {THREE.Group} layer The layer to add light sources to. + * @param {object} group The floor plan group. + */ +beestat.component.scene.prototype.add_light_sources_ = function(layer, group) { + if (Array.isArray(group.light_sources) !== true || group.light_sources.length === 0) { + return; + } + if (Array.isArray(this.light_sources_) !== true) { + this.light_sources_ = []; + } + + if (this.debug_.light_source_orbs === true) { + if (this.light_source_marker_geometry_ === undefined) { + this.light_source_marker_geometry_ = new THREE.SphereGeometry(2.2, 12, 12); + } + if (this.light_source_glow_geometry_ === undefined) { + this.light_source_glow_geometry_ = new THREE.SphereGeometry(6, 16, 16); + } + if (this.light_source_marker_material_ === undefined) { + this.light_source_marker_material_ = new THREE.MeshStandardMaterial({ + 'roughness': 0.2, + 'metalness': 0.05 + }); + } + if (this.light_source_glow_material_ === undefined) { + this.light_source_glow_material_ = new THREE.MeshBasicMaterial({ + 'transparent': true, + 'opacity': 0.28, + 'depthWrite': false, + 'blending': THREE.AdditiveBlending + }); + } + } + + const group_elevation = Number(group.elevation || 0); + const floor_thickness = Number(beestat.component.scene.room_floor_thickness || 0); + + group.light_sources.forEach(function(light_source) { + const x = Number(light_source.x || 0); + const y = Number(light_source.y || 0); + const elevation = Number(light_source.elevation !== undefined ? light_source.elevation : 84); + const z = -group_elevation - floor_thickness - elevation; + let intensity_level = 2; + if (light_source.intensity === 'dim') { + intensity_level = 1; + } else if (light_source.intensity === 'bright') { + intensity_level = 3; + } + const light_intensity = 0.9 * intensity_level; + const light_color = this.get_light_color_from_temperature_(light_source.temperature_k); + + if (this.debug_.light_source_orbs === true) { + const marker = new THREE.Mesh( + this.light_source_marker_geometry_, + this.light_source_marker_material_.clone() + ); + marker.material.color.copy(light_color); + marker.material.emissive.copy(light_color); + marker.material.emissiveIntensity = 0.9 + (intensity_level * 0.35); + marker.position.set(x, y, z); + marker.castShadow = false; + marker.receiveShadow = false; + marker.userData.is_light_source = true; + layer.add(marker); + + const glow = new THREE.Mesh( + this.light_source_glow_geometry_, + this.light_source_glow_material_.clone() + ); + glow.material.color.copy(light_color); + glow.material.opacity = 0.15 + (intensity_level * 0.08); + glow.position.set(x, y, z); + glow.castShadow = false; + glow.receiveShadow = false; + glow.userData.is_light_source = true; + layer.add(glow); + } + + const light = new THREE.PointLight(light_color, light_intensity, 240, 2); + light.userData.base_intensity = light_intensity; + light.intensity = light_intensity * Number(this.target_light_source_intensity_multiplier_ || 0); + light.position.set(x, y, z); + light.castShadow = false; + light.userData.is_light_source = true; + layer.add(light); + this.light_sources_.push(light); + }, this); +}; + +/** + * Add warm interior point lights, one per room. Lights are invisible and their + * intensity is animated based on night/day state. + * + * @param {object} floor_plan The floor plan data. + */ +beestat.component.scene.prototype.add_interior_lights_ = function(floor_plan) { + this.interior_lights_ = []; + this.interior_light_group_ = new THREE.Group(); + this.floor_plan_group_.add(this.interior_light_group_); + this.layers_['interior_lights'] = this.interior_light_group_; + let shadowed_light_count = 0; + + floor_plan.data.groups.forEach(function(group) { + group.rooms.forEach((room) => { + if (room.points === undefined || room.points.length < 3) { + return; + } + + const geojson_polygon = []; + room.points.forEach(function(point) { + geojson_polygon.push([ + point.x, + point.y + ]); + }); + const light_point = polylabel([geojson_polygon]); + + const group_elevation = Number(group.elevation || 0); + const room_height = Number(room.height || group.height || 96); + const room_elevation = Number(room.elevation !== undefined ? room.elevation : group_elevation); + + const light = new THREE.PointLight(0xffd79a, 0, 170, 2); + light.position.set( + Number(room.x || 0) + light_point[0], + Number(room.y || 0) + light_point[1], + -room_elevation - (room_height * 0.45) + ); + if (shadowed_light_count < beestat.component.scene.interior_light_shadow_max) { + light.castShadow = true; + light.shadow.mapSize.width = 512; + light.shadow.mapSize.height = 512; + light.shadow.bias = -0.0012; + light.shadow.normalBias = 0.025; + light.shadow.radius = 2; + light.shadow.camera.near = 1; + light.shadow.camera.far = 220; + shadowed_light_count++; + } else { + light.castShadow = false; + } + + this.interior_light_group_.add(light); + this.interior_lights_.push(light); + }); + }, this); }; /** @@ -2484,6 +2990,21 @@ beestat.component.scene.prototype.add_floor_plan_ = function() { opening_cutter_debug_layer ); + const openings_layer = new THREE.Group(); + this.floor_plan_group_.add(openings_layer); + this.layers_['openings'] = openings_layer; + floor_plan.data.groups.forEach(function(group) { + self.add_opening_fixtures_(openings_layer, group); + }); + + this.light_sources_ = []; + const light_sources_layer = new THREE.Group(); + this.floor_plan_group_.add(light_sources_layer); + this.layers_['light_sources'] = light_sources_layer; + floor_plan.data.groups.forEach(function(group) { + self.add_light_sources_(light_sources_layer, group); + }); + if (this.debug_.openings === true) { const openings_debug_layer = new THREE.Group(); this.floor_plan_group_.add(openings_debug_layer); @@ -4177,7 +4698,7 @@ beestat.component.scene.prototype.create_round_tree_ = function(height, max_diam if (foliage_enabled === true) { this.tree_branch_groups_.push(branches); } - branches.visible = foliage_enabled !== true; + branches.visible = this.debug_.hide_tree_branches !== true && foliage_enabled !== true; tree.add(branches); if (foliage_enabled === true) { tree.add(foliage); @@ -4272,7 +4793,8 @@ beestat.component.scene.prototype.update_tree_foliage_season_ = function() { 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; + // Debug override can force branch meshes hidden at all times. + branch_group.visible = this.debug_.hide_tree_branches !== true && state.visible !== true; } } } @@ -4682,6 +5204,38 @@ beestat.component.scene.prototype.set_labels = function(labels) { return this; }; +/** + * Set whether room floor meshes can be hovered/selected. + * + * @param {boolean} enabled + * + * @return {beestat.component.scene} + */ +beestat.component.scene.prototype.set_room_interaction_enabled = function(enabled) { + this.room_interaction_enabled_ = enabled !== false; + + if (this.room_interaction_enabled_ !== true) { + if (this.intersected_mesh_ !== undefined) { + if ( + this.intersected_mesh_.material !== undefined && + this.intersected_mesh_.material.emissive !== undefined + ) { + this.intersected_mesh_.material.emissive.setHex(0x000000); + } + delete this.intersected_mesh_; + } + if (this.active_mesh_ !== undefined) { + delete this.active_mesh_; + } + document.body.style.cursor = ''; + if (this.rendered_ === true) { + this.update_(); + } + } + + return this; +}; + /** * Set the gradient. * @@ -4875,6 +5429,24 @@ beestat.component.scene.prototype.dispose = function() { if (this.csg_cutter_material_ !== undefined) { this.csg_cutter_material_.dispose(); } + if (this.opening_frame_material_ !== undefined) { + this.opening_frame_material_.dispose(); + } + if (this.window_pane_material_ !== undefined) { + this.window_pane_material_.dispose(); + } + if (this.light_source_marker_material_ !== undefined) { + this.light_source_marker_material_.dispose(); + } + if (this.light_source_glow_material_ !== undefined) { + this.light_source_glow_material_.dispose(); + } + if (this.light_source_marker_geometry_ !== undefined) { + this.light_source_marker_geometry_.dispose(); + } + if (this.light_source_glow_geometry_ !== undefined) { + this.light_source_glow_geometry_.dispose(); + } // Clean up THREE.js scene resources if (this.scene_ !== undefined) { diff --git a/js/js.php b/js/js.php index 859d14a..e72bbc6 100755 --- a/js/js.php +++ b/js/js.php @@ -175,6 +175,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL;