diff --git a/js/component/card/floor_plan_editor.js b/js/component/card/floor_plan_editor.js index db4b237..7110f47 100644 --- a/js/component/card/floor_plan_editor.js +++ b/js/component/card/floor_plan_editor.js @@ -82,6 +82,9 @@ beestat.component.card.floor_plan_editor.prototype.decorate_contents_ = function if (group.trees === undefined) { group.trees = []; } + if (group.openings === undefined) { + group.openings = []; + } group.rooms.forEach(function(room) { if (room.room_id === undefined) { @@ -121,6 +124,28 @@ beestat.component.card.floor_plan_editor.prototype.decorate_contents_ = function tree.editor_locked = false; } }); + + group.openings.forEach(function(opening) { + if (opening.opening_id === undefined) { + opening.opening_id = window.crypto.randomUUID(); + } + if (opening.editor_hidden === undefined) { + opening.editor_hidden = opening.editor_visible === false; + } + delete opening.editor_visible; + if (opening.editor_locked === undefined) { + opening.editor_locked = false; + } + if (['empty', 'door', 'window'].includes(opening.type) !== true) { + opening.type = 'empty'; + } + if (opening.width === undefined) { + opening.width = 36; + } + if (opening.height === undefined) { + opening.height = 80; + } + }); }); /** @@ -417,6 +442,14 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func self.update_floor_plan_(); self.rerender(); }); + this.floor_plan_.addEventListener('add_opening', function() { + self.update_floor_plan_(); + self.rerender(); + }); + this.floor_plan_.addEventListener('remove_opening', function() { + self.update_floor_plan_(); + self.rerender(); + }); this.floor_plan_.addEventListener('undo', function() { self.update_floor_plan_(); self.rerender(); @@ -430,7 +463,8 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func this.entity_index_ = { 'rooms': {}, 'surfaces': {}, - 'trees': {} + 'trees': {}, + 'openings': {} }; const on_entity_update = function() { @@ -484,6 +518,17 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func .set_group(self.state_.active_group); surface_entity.render(self.floor_plan_.get_g()); }); + (group_below.openings || []).slice().reverse().forEach(function(opening) { + if (opening.editor_hidden === true) { + return; + } + + const opening_entity = new beestat.component.floor_plan_entity.opening(self.floor_plan_, self.state_) + .set_enabled(false) + .set_opening(opening) + .set_group(self.state_.active_group); + opening_entity.render(self.floor_plan_.get_g()); + }); } // Loop over the rooms in this group and add them. @@ -571,6 +616,42 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func this.state_.active_surface_entity.render(this.floor_plan_.get_g()); } + // Loop over openings in this group and add them. + let active_opening_entity; + (this.state_.active_group.openings || []).slice().reverse().forEach(function(opening) { + if (opening.editor_hidden === true) { + return; + } + + const opening_entity = new beestat.component.floor_plan_entity.opening(self.floor_plan_, self.state_) + .set_enabled(opening.editor_locked !== true) + .set_opening(opening) + .set_group(self.state_.active_group); + + opening_entity.addEventListener('update', on_entity_update); + opening_entity.addEventListener('activate', on_entity_activate); + opening_entity.addEventListener('inactivate', on_entity_inactivate); + + if ( + self.state_.active_opening_entity !== undefined && + opening.opening_id === self.state_.active_opening_entity.get_opening().opening_id + ) { + delete self.state_.active_opening_entity; + active_opening_entity = opening_entity; + } + + opening_entity.render(self.floor_plan_.get_g()); + self.entity_index_.openings[opening.opening_id] = opening_entity; + }); + + if (active_opening_entity !== undefined) { + active_opening_entity.set_active(true); + } + + if (this.state_.active_opening_entity !== undefined) { + this.state_.active_opening_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) { @@ -632,11 +713,16 @@ beestat.component.card.floor_plan_editor.prototype.update_layers_sidebar_ = func * Select an object from the layers sidebar. * * @param {object} group - * @param {string} type rooms|surfaces|trees + * @param {string} type rooms|surfaces|openings|trees * @param {string} object_id */ beestat.component.card.floor_plan_editor.prototype.select_layer_object_ = function(group, type, object_id) { - const normalized_type = type === 'room' ? 'rooms' : type; + let normalized_type = type; + if (normalized_type === 'room') { + normalized_type = 'rooms'; + } else if (normalized_type === 'opening') { + normalized_type = 'openings'; + } const object = this.get_layer_object_by_id_(group, normalized_type, object_id); const is_active_group = ( this.state_.active_group !== undefined && @@ -670,7 +756,7 @@ beestat.component.card.floor_plan_editor.prototype.select_layer_object_ = functi * Set an object's editor visibility. * * @param {object} group - * @param {string} type rooms|surfaces|trees + * @param {string} type rooms|surfaces|openings|trees * @param {string} object_id * @param {boolean} visible */ @@ -703,6 +789,13 @@ beestat.component.card.floor_plan_editor.prototype.set_layer_object_visibility_ ) { this.state_.active_tree_entity.set_active(false); } + if ( + type === 'openings' && + this.state_.active_opening_entity !== undefined && + this.state_.active_opening_entity.get_opening().opening_id === object_id + ) { + this.state_.active_opening_entity.set_active(false); + } } this.floor_plan_.update_infobox(); @@ -716,7 +809,7 @@ beestat.component.card.floor_plan_editor.prototype.set_layer_object_visibility_ * Set an object's editor lock. * * @param {object} group - * @param {string} type rooms|surfaces|trees + * @param {string} type rooms|surfaces|openings|trees * @param {string} object_id * @param {boolean} locked */ @@ -750,6 +843,13 @@ beestat.component.card.floor_plan_editor.prototype.set_layer_object_locked_ = fu ) { this.state_.active_tree_entity.set_active(false); } + if ( + type === 'openings' && + this.state_.active_opening_entity !== undefined && + this.state_.active_opening_entity.get_opening().opening_id === object_id + ) { + this.state_.active_opening_entity.set_active(false); + } } this.floor_plan_.update_infobox(); @@ -763,7 +863,7 @@ beestat.component.card.floor_plan_editor.prototype.set_layer_object_locked_ = fu * Lock or unlock all objects in a layer type. * * @param {object} group - * @param {string} type rooms|surfaces|trees + * @param {string} type rooms|surfaces|openings|trees * @param {boolean} locked */ beestat.component.card.floor_plan_editor.prototype.set_layer_locked_ = function(group, type, locked) { @@ -783,7 +883,7 @@ beestat.component.card.floor_plan_editor.prototype.set_layer_locked_ = function( * Hide or show all objects in a type layer. * * @param {object} group - * @param {string} type rooms|surfaces|trees + * @param {string} type rooms|surfaces|openings|trees * @param {boolean} visible */ beestat.component.card.floor_plan_editor.prototype.set_layer_visible_ = function(group, type, visible) { @@ -806,7 +906,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', 'trees'].forEach(function(type) { + ['rooms', 'surfaces', 'openings', 'trees'].forEach(function(type) { const collection = group[type] || []; collection.forEach(function(object) { object.editor_locked = locked; @@ -816,6 +916,7 @@ beestat.component.card.floor_plan_editor.prototype.set_group_locked_ = function( if (locked === true) { this.deactivate_active_entity_for_group_type_(group, 'rooms'); 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'); } @@ -829,7 +930,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', 'trees'].forEach(function(type) { + ['rooms', 'surfaces', 'openings', 'trees'].forEach(function(type) { const collection = group[type] || []; collection.forEach(function(object) { object.editor_hidden = visible !== true; @@ -839,6 +940,7 @@ beestat.component.card.floor_plan_editor.prototype.set_group_visible_ = function if (visible !== true) { this.deactivate_active_entity_for_group_type_(group, 'rooms'); 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'); } @@ -849,7 +951,7 @@ beestat.component.card.floor_plan_editor.prototype.set_group_visible_ = function * Deactivate active entity if it belongs to the specified group/type. * * @param {object} group - * @param {string} type rooms|surfaces|trees + * @param {string} type rooms|surfaces|openings|trees */ beestat.component.card.floor_plan_editor.prototype.deactivate_active_entity_for_group_type_ = function(group, type) { if (type === 'rooms' && this.state_.active_room_entity !== undefined) { @@ -870,6 +972,13 @@ beestat.component.card.floor_plan_editor.prototype.deactivate_active_entity_for_ if (this.state_.active_tree_entity.group_ === group) { this.state_.active_tree_entity.set_active(false); } + return; + } + + if (type === 'openings' && this.state_.active_opening_entity !== undefined) { + if (this.state_.active_opening_entity.group_ === group) { + this.state_.active_opening_entity.set_active(false); + } } }; @@ -957,6 +1066,16 @@ beestat.component.card.floor_plan_editor.prototype.ensure_active_entity_visibili ) { delete this.state_.active_tree_entity; } + + if ( + this.state_.active_opening_entity !== undefined && + ( + this.state_.active_opening_entity.get_opening().editor_hidden === true || + this.state_.active_opening_entity.get_opening().editor_locked === true + ) + ) { + delete this.state_.active_opening_entity; + } }; /** @@ -1003,6 +1122,9 @@ beestat.component.card.floor_plan_editor.prototype.get_layer_object_id_key_ = fu if (type === 'surfaces') { return 'surface_id'; } + if (type === 'openings') { + return 'opening_id'; + } return 'tree_id'; }; @@ -1010,7 +1132,7 @@ beestat.component.card.floor_plan_editor.prototype.get_layer_object_id_key_ = fu * Get object by id from a group/type. * * @param {object} group - * @param {string} type rooms|surfaces|trees + * @param {string} type rooms|surfaces|openings|trees * @param {string} object_id * * @return {object|undefined} @@ -1036,6 +1158,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_opening_entity !== undefined) { + type = 'openings'; } else if (this.state_.active_surface_entity !== undefined) { type = 'surfaces'; } else if (this.state_.active_room_entity !== undefined) { @@ -1063,6 +1187,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_opening_entity !== undefined) { + type = 'openings'; + object_id = this.state_.active_opening_entity.get_opening().opening_id; } else if (this.state_.active_surface_entity !== undefined) { type = 'surfaces'; object_id = this.state_.active_surface_entity.get_surface().surface_id; @@ -1117,6 +1244,12 @@ beestat.component.card.floor_plan_editor.prototype.restore_entity_draw_order_ = 'surface_id' ); + append_entities_in_order( + this.state_.active_group.openings || [], + this.entity_index_.openings || {}, + 'opening_id' + ); + const tree_group = this.floor_plan_.get_tree_group_(); if (tree_group === this.state_.active_group) { append_entities_in_order( @@ -1135,6 +1268,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_opening_entity !== undefined) { + this.decorate_info_pane_opening_(parent); } else if (this.state_.active_surface_entity !== undefined) { this.decorate_info_pane_surface_(parent); } else if (this.state_.active_room_entity !== undefined) { @@ -1643,6 +1778,155 @@ beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_surface_ = }); }; +/** + * Decorate the info pane for an opening. + * + * @param {rocket.Elements} parent + */ +beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_opening_ = function(parent) { + const self = this; + const opening = this.state_.active_opening_entity.get_opening(); + + 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); + + let div; + + // Name + div = $.createElement('div'); + grid.appendChild(div); + const name_input = new beestat.component.input.text() + .set_label('Opening Name') + .set_placeholder('Unnamed Opening') + .set_width('100%') + .set_maxlength(50) + .render(div); + + if (opening.name !== undefined) { + name_input.set_value(opening.name); + } + + name_input.addEventListener('input', function() { + opening.name = name_input.get_value(); + self.update_layers_sidebar_(); + }); + name_input.addEventListener('change', function() { + opening.name = name_input.get_value(); + self.update_floor_plan_(); + self.update_layers_sidebar_(); + }); + + // Type + div = $.createElement('div'); + grid.appendChild(div); + 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'}) + .render(div); + + type_input.set_value(['empty', 'door', 'window'].includes(opening.type) ? opening.type : 'empty'); + type_input.addEventListener('change', function() { + opening.type = type_input.get_value(); + 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); + + 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 + }); + self.update_floor_plan_(); + self.rerender(); + } else { + width_input.set_value(beestat.distance({ + 'distance': opening.width || 0, + 'round': 2 + }) || '', false); + } + }); + + // Height + div = $.createElement('div'); + grid.appendChild(div); + const height_input = new beestat.component.input.text() + .set_label('Height (' + beestat.setting('units.distance') + ')') + .set_placeholder(beestat.distance({ + 'distance': opening.height || 0, + 'round': 2 + })) + .set_value(beestat.distance({ + 'distance': opening.height || 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); + + height_input.addEventListener('change', function() { + if (height_input.meets_requirements() === true) { + opening.height = beestat.distance({ + 'distance': height_input.get_value(), + 'input_distance_unit': beestat.setting('units.distance'), + 'output_distance_unit': 'in', + 'round': 2 + }); + self.update_floor_plan_(); + } else { + height_input.set_value(beestat.distance({ + 'distance': opening.height || 0, + 'round': 2 + }) || '', false); + } + }); +}; + /** * Decorate the info pane for a room. * diff --git a/js/component/floor_plan.js b/js/component/floor_plan.js index ed4217e..4e336e8 100644 --- a/js/component/floor_plan.js +++ b/js/component/floor_plan.js @@ -130,7 +130,8 @@ beestat.component.floor_plan.prototype.render = function(parent) { if ( self.state_.active_room_entity !== undefined || self.state_.active_surface_entity !== undefined || - self.state_.active_tree_entity !== undefined + self.state_.active_tree_entity !== undefined || + self.state_.active_opening_entity !== undefined ) { self.clear_room_(); } @@ -152,6 +153,10 @@ beestat.component.floor_plan.prototype.render = function(parent) { if (e.ctrlKey === false && self.has_early_access_() === true) { self.add_tree_(); } + } else if (e.key.toLowerCase() === 'o') { + if (e.ctrlKey === false) { + self.add_opening_(); + } } else if (e.key.toLowerCase() === 's') { self.toggle_snapping_(); } else if ( @@ -225,6 +230,7 @@ beestat.component.floor_plan.prototype.render = function(parent) { const entity = self.state_.active_point_entity || self.state_.active_wall_entity || + self.state_.active_opening_entity || self.state_.active_surface_entity || self.state_.active_room_entity || self.state_.active_tree_entity; @@ -510,6 +516,18 @@ 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 surface this.tile_group_.add_tile(new beestat.component.tile() @@ -550,7 +568,7 @@ beestat.component.floor_plan.prototype.update_toolbar = function() { } } - // Remove selected room, surface, or tree + // Remove selected room, opening, surface, or tree const remove_button = new beestat.component.tile() .set_icon('card_remove_outline') .set_title('Remove [Delete]') @@ -559,6 +577,7 @@ beestat.component.floor_plan.prototype.update_toolbar = function() { if ( this.state_.active_room_entity !== undefined || + this.state_.active_opening_entity !== undefined || this.state_.active_surface_entity !== undefined || this.state_.active_tree_entity !== undefined ) { @@ -800,6 +819,17 @@ beestat.component.floor_plan.prototype.update_infobox = function() { 'units': true }) ); + } else if (this.state_.active_opening_entity !== undefined) { + const opening = this.state_.active_opening_entity.get_opening(); + parts.push('Opening'); + parts.push((opening.type || 'empty').toUpperCase()); + parts.push( + beestat.distance({ + 'distance': opening.width || 0, + 'units': true, + 'round': 0 + }) + ' w' + ); } else { parts.push(this.state_.active_group.name || 'Unnamed Floor'); parts.push( @@ -1016,9 +1046,14 @@ beestat.component.floor_plan.prototype.remove_room_ = function() { }; /** - * Remove the currently active selectable entity (surface, room, or tree). + * Remove the currently active selectable entity (surface, room, opening, or tree). */ beestat.component.floor_plan.prototype.remove_active_entity_ = function() { + if (this.state_.active_opening_entity !== undefined) { + this.remove_opening_(); + return; + } + if (this.state_.active_surface_entity !== undefined) { this.remove_surface_(); return; @@ -1061,6 +1096,9 @@ beestat.component.floor_plan.prototype.set_active_group = function(group) { if (this.state_.active_tree_entity !== undefined) { this.state_.active_tree_entity.set_active(false); } + if (this.state_.active_opening_entity !== undefined) { + this.state_.active_opening_entity.set_active(false); + } this.state_.active_group = group; this.dispatchEvent('change_group'); @@ -1104,6 +1142,71 @@ beestat.component.floor_plan.prototype.remove_surface_ = function() { this.dispatchEvent('remove_surface'); }; +/** + * Add a new opening. + * + * @param {object} opening Optional opening to copy from. + */ +beestat.component.floor_plan.prototype.add_opening_ = function(opening) { + this.save_buffer(); + + if (this.state_.active_group.openings === undefined) { + this.state_.active_group.openings = []; + } + + 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 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, + 'height': height, + 'type': ['empty', 'door', 'window'].includes((opening || {}).type) ? opening.type : 'empty', + 'name': (opening || {}).name, + 'editor_hidden': false, + 'editor_locked': false + }; + + this.state_.active_group.openings.unshift(new_opening); + new beestat.component.floor_plan_entity.opening(this, this.state_) + .set_opening(new_opening) + .set_group(this.state_.active_group) + .set_active(true); + + this.dispatchEvent('add_opening'); +}; + +/** + * Remove the currently active opening. + */ +beestat.component.floor_plan.prototype.remove_opening_ = function() { + this.save_buffer(); + + if ( + this.state_.active_opening_entity === undefined || + this.state_.active_group.openings === undefined + ) { + return; + } + + const self = this; + const index = this.state_.active_group.openings.findIndex(function(opening) { + return opening === self.state_.active_opening_entity.get_opening(); + }); + + if (index === -1) { + return; + } + + this.state_.active_opening_entity.set_active(false); + this.state_.active_group.openings.splice(index, 1); + + this.dispatchEvent('remove_opening'); +}; + /** * Add a new tree to the first floor. * @@ -1212,6 +1315,9 @@ beestat.component.floor_plan.prototype.clear_room_ = function() { if (this.state_.active_point_entity !== undefined) { this.state_.active_point_entity.set_active(false); } + if (this.state_.active_opening_entity !== undefined) { + this.state_.active_opening_entity.set_active(false); + } }; /** @@ -1474,6 +1580,7 @@ beestat.component.floor_plan.prototype.save_buffer = function(clear = true) { 'active_room_entity': this.state_.active_room_entity, '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_group_id': this.state_.active_group.group_id }); @@ -1523,6 +1630,8 @@ beestat.component.floor_plan.prototype.undo_ = function() { this.state_.buffer[this.state_.buffer_pointer].active_surface_entity; this.state_.active_tree_entity = 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; // Restore any active group. this.state_.active_group_id = @@ -1572,6 +1681,8 @@ beestat.component.floor_plan.prototype.redo_ = function() { this.state_.buffer[this.state_.buffer_pointer].active_surface_entity; this.state_.active_tree_entity = 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; // Restore any active group. this.state_.active_group_id = @@ -1600,3 +1711,5 @@ beestat.component.floor_plan.prototype.can_redo_ = function() { return this.state_.buffer !== undefined && this.state_.buffer_pointer + 1 < this.state_.buffer.length; }; + + diff --git a/js/component/floor_plan_entity/opening.js b/js/component/floor_plan_entity/opening.js new file mode 100644 index 0000000..e996395 --- /dev/null +++ b/js/component/floor_plan_entity/opening.js @@ -0,0 +1,340 @@ +/** + * Floor plan opening (empty, door, window). + */ +beestat.component.floor_plan_entity.opening = function() { + this.enabled_ = true; + this.resize_mode_ = null; + + beestat.component.floor_plan_entity.apply(this, arguments); +}; +beestat.extend(beestat.component.floor_plan_entity.opening, beestat.component.floor_plan_entity); + +/** + * Decorate. + * + * @param {SVGGElement} parent + */ +beestat.component.floor_plan_entity.opening.prototype.decorate_ = function(parent) { + this.decorate_opening_(parent); + + if (this.enabled_ === true) { + this.set_draggable_(true); + } +}; + +/** + * Build opening visuals. + * + * @param {SVGGElement} parent + */ +beestat.component.floor_plan_entity.opening.prototype.decorate_opening_ = function(parent) { + const self = this; + + this.path_ = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + this.path_.style.fill = 'none'; + this.path_.style.strokeLinecap = 'round'; + this.path_.style.cursor = this.enabled_ === true ? 'move' : '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); + + 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); + }); + } + }); + + if (this.enabled_ === true) { + this.path_.addEventListener('click', function(e) { + e.stopPropagation(); + self.set_active(true); + }); + this.path_.addEventListener('touchstart', function(e) { + e.stopPropagation(); + self.set_active(true); + }); + } + + 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; + + this.path_.setAttribute('d', 'M' + (-half_width) + ',0 L' + half_width + ',0'); + 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'); + + this.set_xy(this.opening_.x, this.opening_.y); +}; + +/** + * Handle after mousedown. + */ +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 + }; +}; + +/** + * Handle dragging. + * + * @param {Event} e + */ +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 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(); + + 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); + + let next_left = start_left; + let next_right = start_right; + + 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); + } + + 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)); + + 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; + } + + 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; + + 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); +}; + +/** + * Cleanup after mouseup. + */ +beestat.component.floor_plan_entity.opening.prototype.after_mouseup_handler_ = function() { + this.resize_mode_ = null; + this.update(); +}; + +/** + * Set opening. + * + * @param {object} opening + * + * @return {beestat.component.floor_plan_entity.opening} + */ +beestat.component.floor_plan_entity.opening.prototype.set_opening = function(opening) { + this.opening_ = opening; + + if (this.opening_.opening_id === undefined) { + this.opening_.opening_id = window.crypto.randomUUID(); + } + 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.set_xy(this.opening_.x || 0, this.opening_.y || 0); + return this; +}; + +/** + * Set group. + * + * @param {object} group + * + * @return {beestat.component.floor_plan_entity.opening} + */ +beestat.component.floor_plan_entity.opening.prototype.set_group = function(group) { + this.group_ = group; + return this; +}; + +/** + * Get opening. + * + * @return {object} + */ +beestat.component.floor_plan_entity.opening.prototype.get_opening = function() { + return this.opening_; +}; + +/** + * Set enabled. + * + * @param {boolean} enabled + * + * @return {beestat.component.floor_plan_entity.opening} + */ +beestat.component.floor_plan_entity.opening.prototype.set_enabled = function(enabled) { + this.enabled_ = enabled; + return this; +}; + +/** + * Set x/y with clamping. + * + * @param {number} x + * @param {number} y + * @param {string} event + * + * @return {beestat.component.floor_plan_entity.opening} + */ +beestat.component.floor_plan_entity.opening.prototype.set_xy = function(x, y, event = 'lesser_update') { + if (event === 'update') { + this.floor_plan_.save_buffer(); + } + + const grid_half = this.floor_plan_.get_grid_pixels() / 2; + const half_width = Math.max(12, Number(this.opening_.width || 0)) / 2; + + 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))); + + this.opening_.x = Math.round(clamped_x); + this.opening_.y = Math.round(clamped_y); + + beestat.component.floor_plan_entity.prototype.set_xy.apply( + this, + [ + this.opening_.x, + this.opening_.y + ] + ); + + this.dispatchEvent(event); + return this; +}; + +/** + * Set active state. + * + * @param {boolean} active + * + * @return {beestat.component.floor_plan_entity.opening} + */ +beestat.component.floor_plan_entity.opening.prototype.set_active = function(active) { + if (active === true && this.enabled_ !== true) { + return this; + } + + if (active === true) { + 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_tree_entity !== undefined) { + this.state_.active_tree_entity.set_active(false); + } + if (this.state_.active_surface_entity !== undefined) { + this.state_.active_surface_entity.set_active(false); + } + if (this.state_.active_room_entity !== undefined) { + this.state_.active_room_entity.set_active(false); + } + } + + if (active !== this.active_) { + this.active_ = active; + + if (this.active_ === true) { + if ( + this.state_.active_opening_entity !== undefined && + this.state_.active_opening_entity.get_opening() !== this.opening_ + ) { + this.state_.active_opening_entity.set_active(false); + } + + this.state_.active_opening_entity = this; + this.dispatchEvent('activate'); + this.bring_to_front_(); + } else { + delete this.state_.active_opening_entity; + this.dispatchEvent('inactivate'); + } + + if (this.rendered_ === true) { + this.rerender(); + } + } + + return this; +}; + +/** + * Get color by opening type. + * + * @return {string} + */ +beestat.component.floor_plan_entity.opening.prototype.get_opening_color_ = function() { + switch (this.opening_.type) { + case 'door': + return beestat.style.color.green.base; + case 'window': + return beestat.style.color.lightblue.light; + case 'empty': + default: + return beestat.style.color.gray.light; + } +}; diff --git a/js/component/floor_plan_entity/room.js b/js/component/floor_plan_entity/room.js index afe8486..d82bd61 100644 --- a/js/component/floor_plan_entity/room.js +++ b/js/component/floor_plan_entity/room.js @@ -256,6 +256,10 @@ beestat.component.floor_plan_entity.room.prototype.set_active = function(active) this.state_.active_surface_entity.set_active(false); this.floor_plan_.update_toolbar(); } + if (this.state_.active_opening_entity !== undefined) { + this.state_.active_opening_entity.set_active(false); + this.floor_plan_.update_toolbar(); + } if (active !== this.active_) { this.active_ = active; diff --git a/js/component/floor_plan_entity/surface.js b/js/component/floor_plan_entity/surface.js index 41f964d..89c22ba 100644 --- a/js/component/floor_plan_entity/surface.js +++ b/js/component/floor_plan_entity/surface.js @@ -113,6 +113,10 @@ beestat.component.floor_plan_entity.surface.prototype.set_active = function(acti this.state_.active_tree_entity.set_active(false); this.floor_plan_.update_toolbar(); } + if (this.state_.active_opening_entity !== undefined) { + this.state_.active_opening_entity.set_active(false); + this.floor_plan_.update_toolbar(); + } if (active !== this.active_) { this.active_ = active; diff --git a/js/component/floor_plan_entity/tree.js b/js/component/floor_plan_entity/tree.js index 9deb777..81e83ac 100644 --- a/js/component/floor_plan_entity/tree.js +++ b/js/component/floor_plan_entity/tree.js @@ -209,6 +209,9 @@ beestat.component.floor_plan_entity.tree.prototype.set_active = function(active) 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); + } this.state_.active_tree_entity = this; this.dispatchEvent('activate'); diff --git a/js/component/floor_plan_layers_sidebar.js b/js/component/floor_plan_layers_sidebar.js index 4010392..4ac8c61 100644 --- a/js/component/floor_plan_layers_sidebar.js +++ b/js/component/floor_plan_layers_sidebar.js @@ -95,6 +95,7 @@ beestat.component.floor_plan_layers_sidebar.prototype.decorate_ = function(paren sidebar_state.collapsed_groups[group.group_id] = true; 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 + '.rooms'] = true; }); sidebar_state.initialized_collapsed = true; @@ -110,6 +111,9 @@ beestat.component.floor_plan_layers_sidebar.prototype.decorate_ = function(paren if (sidebar_state.collapsed_types[group.group_id + '.surfaces'] === undefined) { sidebar_state.collapsed_types[group.group_id + '.surfaces'] = true; } + 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 + '.rooms'] === undefined) { sidebar_state.collapsed_types[group.group_id + '.rooms'] = true; } @@ -122,6 +126,7 @@ beestat.component.floor_plan_layers_sidebar.prototype.decorate_ = function(paren const group_objects = [] .concat(group.trees || []) .concat(group.surfaces || []) + .concat(group.openings || []) .concat(group.rooms || []); const has_group_objects = group_objects.length > 0; const group_all_hidden = has_group_objects === true && group_objects.every(function(object) { @@ -212,6 +217,9 @@ beestat.component.floor_plan_layers_sidebar.prototype.decorate_ = function(paren if ((group.surfaces || []).length > 0) { self.on_toggle_layer_visibility_(group, 'surfaces', group_all_hidden === true); } + if ((group.openings || []).length > 0) { + self.on_toggle_layer_visibility_(group, 'openings', group_all_hidden === true); + } if ((group.rooms || []).length > 0) { self.on_toggle_layer_visibility_(group, 'rooms', group_all_hidden === true); } @@ -246,6 +254,9 @@ beestat.component.floor_plan_layers_sidebar.prototype.decorate_ = function(paren if ((group.surfaces || []).length > 0) { self.on_toggle_layer_lock_(group, 'surfaces', !group_all_locked); } + if ((group.openings || []).length > 0) { + self.on_toggle_layer_lock_(group, 'openings', !group_all_locked); + } if ((group.rooms || []).length > 0) { self.on_toggle_layer_lock_(group, 'rooms', !group_all_locked); } @@ -301,6 +312,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, + 'openings', + 'Opening', + font_size_small, + scroll_to, + scroll_to_row + ); scroll_to_row = self.decorate_group_type_( group_panel, group, @@ -968,6 +988,9 @@ beestat.component.floor_plan_layers_sidebar.prototype.get_type_icon_ = function( if (type === 'surfaces') { return 'texture_box'; } + if (type === 'openings') { + return 'window_closed_variant'; + } return 'view_quilt'; }; @@ -1013,6 +1036,9 @@ beestat.component.floor_plan_layers_sidebar.prototype.get_object_id_ = function( if (type === 'surfaces') { return object.surface_id; } + if (type === 'openings') { + return object.opening_id; + } return object.tree_id; }; @@ -1080,6 +1106,13 @@ beestat.component.floor_plan_layers_sidebar.prototype.is_active_row_ = function( ) { return true; } + if ( + type === 'openings' && + this.state_.active_opening_entity !== undefined && + this.state_.active_opening_entity.get_opening().opening_id === object_id + ) { + return true; + } return false; }; diff --git a/js/component/scene.js b/js/component/scene.js index 0732893..4806b42 100644 --- a/js/component/scene.js +++ b/js/component/scene.js @@ -508,7 +508,9 @@ beestat.component.scene.prototype.decorate_ = function(parent) { 'moon_light_helper': false, 'watcher': false, 'roof_edges': false, - 'straight_skeleton': false + 'straight_skeleton': false, + 'openings': true, + 'opening_cutters': false }; this.width_ = this.state_.scene_width || 800; @@ -2138,6 +2140,8 @@ beestat.component.scene.prototype.add_walls_ = function(layer, group) { const mesh = new THREE.Mesh(geometry, material); mesh.position.z = -wall_height - elevation; mesh.userData.is_wall = true; + mesh.userData.group_id = group.group_id; + mesh.userData.wall_cuttable = true; mesh.layers.set(beestat.component.scene.layer_visible); mesh.castShadow = true; mesh.receiveShadow = true; @@ -2147,6 +2151,206 @@ beestat.component.scene.prototype.add_walls_ = function(layer, group) { } }; +/** + * Build an opening cutter mesh for CSG subtraction. + * + * @param {object} group The floor plan group. + * @param {object} opening The opening. + * @return {?THREE.Mesh} Opening cutter mesh or null if opening is not cuttable. + */ +beestat.component.scene.prototype.build_opening_cutter_mesh_ = function(group, opening) { + if (opening.editor_hidden === true) { + return null; + } + + const width = Math.max(12, Number(opening.width || 0)); + const height = Math.max(1, Number(opening.height || 0)); + const wall_thickness = Number(beestat.component.scene.wall_thickness || 4); + const depth = Math.max(0.5, wall_thickness); + const elevation = Number(group.elevation || 0); + + if (this.csg_cutter_material_ === undefined) { + this.csg_cutter_material_ = new THREE.MeshBasicMaterial({ + 'visible': false + }); + } + + const geometry = new THREE.BoxGeometry(width, depth, height); + const cutter = new THREE.Mesh(geometry, this.csg_cutter_material_); + cutter.position.set( + Number(opening.x || 0), + Number(opening.y || 0), + -elevation - (height / 2) + ); + cutter.updateMatrix(); + cutter.updateMatrixWorld(true); + + return cutter; +}; + +/** + * Add a debug wireframe for an opening cutter. + * + * @param {THREE.Group} layer The debug layer. + * @param {THREE.Mesh} cutter The cutter mesh. + */ +beestat.component.scene.prototype.add_opening_cutter_debug_ = function(layer, cutter) { + const edges_geometry = new THREE.EdgesGeometry(cutter.geometry); + const wireframe = new THREE.LineSegments( + edges_geometry, + new THREE.LineBasicMaterial({ + 'color': 0xff7700 + }) + ); + wireframe.position.copy(cutter.position); + wireframe.rotation.copy(cutter.rotation); + wireframe.scale.copy(cutter.scale); + wireframe.layers.set(beestat.component.scene.layer_visible); + + layer.add(wireframe); +}; + +/** + * Subtract opening cutters from wall meshes. + * + * @param {THREE.Group} walls_layer The wall mesh layer. + * @param {object} floor_plan The floor plan data. + * @param {THREE.Group=} opening_cutter_debug_layer Optional debug cutter layer. + */ +beestat.component.scene.prototype.apply_opening_cuts_ = function( + walls_layer, + floor_plan, + opening_cutter_debug_layer +) { + if (window.CSG === undefined || typeof window.CSG.subtract !== 'function') { + return; + } + + const wall_meshes = walls_layer.children.filter(function(child) { + return ( + child !== undefined && + child.type === 'Mesh' && + child.userData !== undefined && + child.userData.wall_cuttable === true + ); + }); + + floor_plan.data.groups.forEach(function(group) { + const openings = group.openings || []; + if (openings.length === 0) { + return; + } + + const group_wall_meshes = wall_meshes.filter(function(mesh) { + return mesh.userData.group_id === group.group_id; + }); + if (group_wall_meshes.length === 0) { + return; + } + + openings.forEach((opening) => { + const cutter = this.build_opening_cutter_mesh_(group, opening); + if (cutter === null) { + return; + } + + if (opening_cutter_debug_layer !== undefined) { + this.add_opening_cutter_debug_(opening_cutter_debug_layer, cutter); + } + + const cutter_box = new THREE.Box3().setFromObject(cutter); + + group_wall_meshes.forEach(function(wall_mesh) { + const wall_box = new THREE.Box3().setFromObject(wall_mesh); + if (wall_box.intersectsBox(cutter_box) !== true) { + return; + } + + try { + wall_mesh.updateMatrix(); + wall_mesh.updateMatrixWorld(true); + + const result_mesh = window.CSG.subtract(wall_mesh, cutter); + if ( + result_mesh === undefined || + result_mesh.geometry === undefined || + result_mesh.geometry.attributes === undefined || + result_mesh.geometry.attributes.position === undefined || + result_mesh.geometry.attributes.position.count === 0 + ) { + return; + } + + result_mesh.geometry.computeBoundingBox(); + result_mesh.geometry.computeBoundingSphere(); + result_mesh.geometry.computeVertexNormals(); + + const old_geometry = wall_mesh.geometry; + wall_mesh.geometry = result_mesh.geometry; + wall_mesh.castShadow = true; + wall_mesh.receiveShadow = true; + wall_mesh.layers.set(beestat.component.scene.layer_visible); + wall_mesh.updateMatrix(); + wall_mesh.updateMatrixWorld(true); + + if (old_geometry !== undefined) { + old_geometry.dispose(); + } + } catch (error) { + // Keep original wall mesh if CSG subtraction fails. + } + }); + + cutter.geometry.dispose(); + }); + }, this); +}; + +/** + * Add red wireframe boxes to visualize opening placement in 3D. + * + * @param {THREE.Group} layer The layer to add opening debug to. + * @param {object} group The floor plan group. + */ +beestat.component.scene.prototype.add_openings_debug_ = function(layer, group) { + if (group.openings === undefined || group.openings.length === 0) { + return; + } + + const wall_thickness = beestat.component.scene.wall_thickness; + + group.openings.forEach(function(opening) { + if (opening.editor_hidden === true) { + return; + } + + const width = Math.max(12, Number(opening.width || 0)); + const height = Math.max(1, Number(opening.height || 0)); + const elevation = group.elevation || 0; + + const geometry = new THREE.BoxGeometry( + width, + wall_thickness, + height + ); + + const edges_geometry = new THREE.EdgesGeometry(geometry); + const wireframe = new THREE.LineSegments( + edges_geometry, + new THREE.LineBasicMaterial({ + 'color': 0xff0000 + }) + ); + + wireframe.position.x = Number(opening.x || 0); + wireframe.position.y = Number(opening.y || 0); + wireframe.position.z = -elevation - (height / 2); + wireframe.layers.set(beestat.component.scene.layer_visible); + + layer.add(wireframe); + }); +}; + /** * Add a helpful debug window that can be refreshed with the contents of * this.debug_info_. @@ -2267,6 +2471,29 @@ beestat.component.scene.prototype.add_floor_plan_ = function() { self.add_walls_(walls_layer, group); }); + let opening_cutter_debug_layer; + if (this.debug_.opening_cutters === true) { + opening_cutter_debug_layer = new THREE.Group(); + this.floor_plan_group_.add(opening_cutter_debug_layer); + this.layers_['opening_cutters_debug'] = opening_cutter_debug_layer; + } + + this.apply_opening_cuts_( + walls_layer, + floor_plan, + opening_cutter_debug_layer + ); + + if (this.debug_.openings === true) { + const openings_debug_layer = new THREE.Group(); + this.floor_plan_group_.add(openings_debug_layer); + this.layers_['openings_debug'] = openings_debug_layer; + + floor_plan.data.groups.forEach(function(group) { + self.add_openings_debug_(openings_debug_layer, group); + }); + } + // Add roofs using straight skeleton this.add_roofs_(); @@ -4645,6 +4872,9 @@ beestat.component.scene.prototype.dispose = function() { if (this.star_texture_ !== undefined) { this.star_texture_.dispose(); } + if (this.csg_cutter_material_ !== undefined) { + this.csg_cutter_material_.dispose(); + } // Clean up THREE.js scene resources if (this.scene_ !== undefined) { diff --git a/js/js.php b/js/js.php index cb9f9cb..859d14a 100755 --- a/js/js.php +++ b/js/js.php @@ -17,10 +17,11 @@ 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; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; @@ -172,6 +173,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; diff --git a/js/lib/three-csg-ts/three-csg-ts.global.js b/js/lib/three-csg-ts/three-csg-ts.global.js new file mode 100644 index 0000000..0872666 --- /dev/null +++ b/js/lib/three-csg-ts/three-csg-ts.global.js @@ -0,0 +1,541 @@ +var BeestatCSG = (() => { + // three-shim.js + var BufferAttribute = window.THREE.BufferAttribute; + var BufferGeometry = window.THREE.BufferGeometry; + var Matrix3 = window.THREE.Matrix3; + var Matrix4 = window.THREE.Matrix4; + var Mesh = window.THREE.Mesh; + var Vector3 = window.THREE.Vector3; + + // node_modules/three-csg-ts/lib/esm/NBuf.js + var NBuf3 = class { + constructor(ct) { + this.top = 0; + this.array = new Float32Array(ct); + } + write(v) { + this.array[this.top++] = v.x; + this.array[this.top++] = v.y; + this.array[this.top++] = v.z; + } + }; + var NBuf2 = class { + constructor(ct) { + this.top = 0; + this.array = new Float32Array(ct); + } + write(v) { + this.array[this.top++] = v.x; + this.array[this.top++] = v.y; + } + }; + + // node_modules/three-csg-ts/lib/esm/Node.js + var Node = class _Node { + constructor(polygons) { + this.plane = null; + this.front = null; + this.back = null; + this.polygons = []; + if (polygons) + this.build(polygons); + } + clone() { + const node = new _Node(); + node.plane = this.plane && this.plane.clone(); + node.front = this.front && this.front.clone(); + node.back = this.back && this.back.clone(); + node.polygons = this.polygons.map((p) => p.clone()); + return node; + } + // Convert solid space to empty space and empty space to solid space. + invert() { + for (let i = 0; i < this.polygons.length; i++) + this.polygons[i].flip(); + this.plane && this.plane.flip(); + this.front && this.front.invert(); + this.back && this.back.invert(); + const temp = this.front; + this.front = this.back; + this.back = temp; + } + // Recursively remove all polygons in `polygons` that are inside this BSP + // tree. + clipPolygons(polygons) { + if (!this.plane) + return polygons.slice(); + let front = new Array(), back = new Array(); + for (let i = 0; i < polygons.length; i++) { + this.plane.splitPolygon(polygons[i], front, back, front, back); + } + if (this.front) + front = this.front.clipPolygons(front); + this.back ? back = this.back.clipPolygons(back) : back = []; + return front.concat(back); + } + // Remove all polygons in this BSP tree that are inside the other BSP tree + // `bsp`. + clipTo(bsp) { + this.polygons = bsp.clipPolygons(this.polygons); + if (this.front) + this.front.clipTo(bsp); + if (this.back) + this.back.clipTo(bsp); + } + // Return a list of all polygons in this BSP tree. + allPolygons() { + let polygons = this.polygons.slice(); + if (this.front) + polygons = polygons.concat(this.front.allPolygons()); + if (this.back) + polygons = polygons.concat(this.back.allPolygons()); + return polygons; + } + // Build a BSP tree out of `polygons`. When called on an existing tree, the + // new polygons are filtered down to the bottom of the tree and become new + // nodes there. Each set of polygons is partitioned using the first polygon + // (no heuristic is used to pick a good split). + build(polygons) { + if (!polygons.length) + return; + if (!this.plane) + this.plane = polygons[0].plane.clone(); + const front = [], back = []; + for (let i = 0; i < polygons.length; i++) { + this.plane.splitPolygon(polygons[i], this.polygons, this.polygons, front, back); + } + if (front.length) { + if (!this.front) + this.front = new _Node(); + this.front.build(front); + } + if (back.length) { + if (!this.back) + this.back = new _Node(); + this.back.build(back); + } + } + }; + + // node_modules/three-csg-ts/lib/esm/Vector.js + var Vector = class _Vector { + constructor(x = 0, y = 0, z = 0) { + this.x = x; + this.y = y; + this.z = z; + } + copy(v) { + this.x = v.x; + this.y = v.y; + this.z = v.z; + return this; + } + clone() { + return new _Vector(this.x, this.y, this.z); + } + negate() { + this.x *= -1; + this.y *= -1; + this.z *= -1; + return this; + } + add(a) { + this.x += a.x; + this.y += a.y; + this.z += a.z; + return this; + } + sub(a) { + this.x -= a.x; + this.y -= a.y; + this.z -= a.z; + return this; + } + times(a) { + this.x *= a; + this.y *= a; + this.z *= a; + return this; + } + dividedBy(a) { + this.x /= a; + this.y /= a; + this.z /= a; + return this; + } + lerp(a, t) { + return this.add(new _Vector().copy(a).sub(this).times(t)); + } + unit() { + return this.dividedBy(this.length()); + } + length() { + return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2) + Math.pow(this.z, 2)); + } + normalize() { + return this.unit(); + } + cross(b) { + const a = this.clone(); + const ax = a.x, ay = a.y, az = a.z; + const bx = b.x, by = b.y, bz = b.z; + this.x = ay * bz - az * by; + this.y = az * bx - ax * bz; + this.z = ax * by - ay * bx; + return this; + } + dot(b) { + return this.x * b.x + this.y * b.y + this.z * b.z; + } + toVector3() { + return new Vector3(this.x, this.y, this.z); + } + }; + + // node_modules/three-csg-ts/lib/esm/Plane.js + var Plane = class _Plane { + constructor(normal, w) { + this.normal = normal; + this.w = w; + this.normal = normal; + this.w = w; + } + clone() { + return new _Plane(this.normal.clone(), this.w); + } + flip() { + this.normal.negate(); + this.w = -this.w; + } + // Split `polygon` by this plane if needed, then put the polygon or polygon + // fragments in the appropriate lists. Coplanar polygons go into either + // `coplanarFront` or `coplanarBack` depending on their orientation with + // respect to this plane. Polygons in front or in back of this plane go into + // either `front` or `back`. + splitPolygon(polygon, coplanarFront, coplanarBack, front, back) { + const COPLANAR = 0; + const FRONT = 1; + const BACK = 2; + const SPANNING = 3; + let polygonType = 0; + const types = []; + for (let i = 0; i < polygon.vertices.length; i++) { + const t = this.normal.dot(polygon.vertices[i].pos) - this.w; + const type = t < -_Plane.EPSILON ? BACK : t > _Plane.EPSILON ? FRONT : COPLANAR; + polygonType |= type; + types.push(type); + } + switch (polygonType) { + case COPLANAR: + (this.normal.dot(polygon.plane.normal) > 0 ? coplanarFront : coplanarBack).push(polygon); + break; + case FRONT: + front.push(polygon); + break; + case BACK: + back.push(polygon); + break; + case SPANNING: { + const f = [], b = []; + for (let i = 0; i < polygon.vertices.length; i++) { + const j = (i + 1) % polygon.vertices.length; + const ti = types[i], tj = types[j]; + const vi = polygon.vertices[i], vj = polygon.vertices[j]; + if (ti != BACK) + f.push(vi); + if (ti != FRONT) + b.push(ti != BACK ? vi.clone() : vi); + if ((ti | tj) == SPANNING) { + const t = (this.w - this.normal.dot(vi.pos)) / this.normal.dot(new Vector().copy(vj.pos).sub(vi.pos)); + const v = vi.interpolate(vj, t); + f.push(v); + b.push(v.clone()); + } + } + if (f.length >= 3) + front.push(new Polygon(f, polygon.shared)); + if (b.length >= 3) + back.push(new Polygon(b, polygon.shared)); + break; + } + } + } + static fromPoints(a, b, c) { + const n = new Vector().copy(b).sub(a).cross(new Vector().copy(c).sub(a)).normalize(); + return new _Plane(n.clone(), n.dot(a)); + } + }; + Plane.EPSILON = 1e-5; + + // node_modules/three-csg-ts/lib/esm/Polygon.js + var Polygon = class _Polygon { + constructor(vertices, shared) { + this.vertices = vertices; + this.shared = shared; + this.plane = Plane.fromPoints(vertices[0].pos, vertices[1].pos, vertices[2].pos); + } + clone() { + return new _Polygon(this.vertices.map((v) => v.clone()), this.shared); + } + flip() { + this.vertices.reverse().map((v) => v.flip()); + this.plane.flip(); + } + }; + + // node_modules/three-csg-ts/lib/esm/Vertex.js + var Vertex = class _Vertex { + constructor(pos, normal, uv, color) { + this.pos = new Vector().copy(pos); + this.normal = new Vector().copy(normal); + this.uv = new Vector().copy(uv); + this.uv.z = 0; + color && (this.color = new Vector().copy(color)); + } + clone() { + return new _Vertex(this.pos, this.normal, this.uv, this.color); + } + // Invert all orientation-specific data (e.g. vertex normal). Called when the + // orientation of a polygon is flipped. + flip() { + this.normal.negate(); + } + // Create a new vertex between this vertex and `other` by linearly + // interpolating all properties using a parameter of `t`. Subclasses should + // override this to interpolate additional properties. + interpolate(other, t) { + return new _Vertex(this.pos.clone().lerp(other.pos, t), this.normal.clone().lerp(other.normal, t), this.uv.clone().lerp(other.uv, t), this.color && other.color && this.color.clone().lerp(other.color, t)); + } + }; + + // node_modules/three-csg-ts/lib/esm/CSG.js + var CSG = class _CSG { + constructor() { + this.polygons = []; + } + static fromPolygons(polygons) { + const csg = new _CSG(); + csg.polygons = polygons; + return csg; + } + static fromGeometry(geom, objectIndex) { + let polys = []; + const posattr = geom.attributes.position; + const normalattr = geom.attributes.normal; + const uvattr = geom.attributes.uv; + const colorattr = geom.attributes.color; + const grps = geom.groups; + let index; + if (geom.index) { + index = geom.index.array; + } else { + index = new Uint16Array(posattr.array.length / posattr.itemSize | 0); + for (let i = 0; i < index.length; i++) + index[i] = i; + } + const triCount = index.length / 3 | 0; + polys = new Array(triCount); + for (let i = 0, pli = 0, l = index.length; i < l; i += 3, pli++) { + const vertices = new Array(3); + for (let j = 0; j < 3; j++) { + const vi = index[i + j]; + const vp = vi * 3; + const vt = vi * 2; + const x = posattr.array[vp]; + const y = posattr.array[vp + 1]; + const z = posattr.array[vp + 2]; + const nx = normalattr.array[vp]; + const ny = normalattr.array[vp + 1]; + const nz = normalattr.array[vp + 2]; + const u = uvattr === null || uvattr === void 0 ? void 0 : uvattr.array[vt]; + const v = uvattr === null || uvattr === void 0 ? void 0 : uvattr.array[vt + 1]; + vertices[j] = new Vertex(new Vector(x, y, z), new Vector(nx, ny, nz), new Vector(u, v, 0), colorattr && new Vector(colorattr.array[vp], colorattr.array[vp + 1], colorattr.array[vp + 2])); + } + if (objectIndex === void 0 && grps && grps.length > 0) { + for (const grp of grps) { + if (i >= grp.start && i < grp.start + grp.count) { + polys[pli] = new Polygon(vertices, grp.materialIndex); + } + } + } else { + polys[pli] = new Polygon(vertices, objectIndex); + } + } + return _CSG.fromPolygons(polys.filter((p) => !Number.isNaN(p.plane.normal.x))); + } + static toGeometry(csg, toMatrix) { + let triCount = 0; + const ps = csg.polygons; + for (const p of ps) { + triCount += p.vertices.length - 2; + } + const geom = new BufferGeometry(); + const vertices = new NBuf3(triCount * 3 * 3); + const normals = new NBuf3(triCount * 3 * 3); + const uvs = new NBuf2(triCount * 2 * 3); + let colors; + const grps = []; + const dgrp = []; + for (const p of ps) { + const pvs = p.vertices; + const pvlen = pvs.length; + if (p.shared !== void 0) { + if (!grps[p.shared]) + grps[p.shared] = []; + } + if (pvlen && pvs[0].color !== void 0) { + if (!colors) + colors = new NBuf3(triCount * 3 * 3); + } + for (let j = 3; j <= pvlen; j++) { + const grp = p.shared === void 0 ? dgrp : grps[p.shared]; + grp.push(vertices.top / 3, vertices.top / 3 + 1, vertices.top / 3 + 2); + vertices.write(pvs[0].pos); + vertices.write(pvs[j - 2].pos); + vertices.write(pvs[j - 1].pos); + normals.write(pvs[0].normal); + normals.write(pvs[j - 2].normal); + normals.write(pvs[j - 1].normal); + if (uvs) { + uvs.write(pvs[0].uv); + uvs.write(pvs[j - 2].uv); + uvs.write(pvs[j - 1].uv); + } + if (colors) { + colors.write(pvs[0].color); + colors.write(pvs[j - 2].color); + colors.write(pvs[j - 1].color); + } + } + } + geom.setAttribute("position", new BufferAttribute(vertices.array, 3)); + geom.setAttribute("normal", new BufferAttribute(normals.array, 3)); + uvs && geom.setAttribute("uv", new BufferAttribute(uvs.array, 2)); + colors && geom.setAttribute("color", new BufferAttribute(colors.array, 3)); + for (let gi = 0; gi < grps.length; gi++) { + if (grps[gi] === void 0) { + grps[gi] = []; + } + } + if (grps.length) { + let index = []; + let gbase = 0; + for (let gi = 0; gi < grps.length; gi++) { + geom.addGroup(gbase, grps[gi].length, gi); + gbase += grps[gi].length; + index = index.concat(grps[gi]); + } + geom.addGroup(gbase, dgrp.length, grps.length); + index = index.concat(dgrp); + geom.setIndex(index); + } + const inv = new Matrix4().copy(toMatrix).invert(); + geom.applyMatrix4(inv); + geom.computeBoundingSphere(); + geom.computeBoundingBox(); + return geom; + } + static fromMesh(mesh, objectIndex) { + const csg = _CSG.fromGeometry(mesh.geometry, objectIndex); + const ttvv0 = new Vector3(); + const tmpm3 = new Matrix3(); + tmpm3.getNormalMatrix(mesh.matrix); + for (let i = 0; i < csg.polygons.length; i++) { + const p = csg.polygons[i]; + for (let j = 0; j < p.vertices.length; j++) { + const v = p.vertices[j]; + v.pos.copy(ttvv0.copy(v.pos.toVector3()).applyMatrix4(mesh.matrix)); + v.normal.copy(ttvv0.copy(v.normal.toVector3()).applyMatrix3(tmpm3)); + } + } + return csg; + } + static toMesh(csg, toMatrix, toMaterial) { + const geom = _CSG.toGeometry(csg, toMatrix); + const m = new Mesh(geom, toMaterial); + m.matrix.copy(toMatrix); + m.matrix.decompose(m.position, m.quaternion, m.scale); + m.rotation.setFromQuaternion(m.quaternion); + m.updateMatrixWorld(); + m.castShadow = m.receiveShadow = true; + return m; + } + static union(meshA, meshB) { + const csgA = _CSG.fromMesh(meshA); + const csgB = _CSG.fromMesh(meshB); + return _CSG.toMesh(csgA.union(csgB), meshA.matrix, meshA.material); + } + static subtract(meshA, meshB) { + const csgA = _CSG.fromMesh(meshA); + const csgB = _CSG.fromMesh(meshB); + return _CSG.toMesh(csgA.subtract(csgB), meshA.matrix, meshA.material); + } + static intersect(meshA, meshB) { + const csgA = _CSG.fromMesh(meshA); + const csgB = _CSG.fromMesh(meshB); + return _CSG.toMesh(csgA.intersect(csgB), meshA.matrix, meshA.material); + } + clone() { + const csg = new _CSG(); + csg.polygons = this.polygons.map((p) => p.clone()).filter((p) => Number.isFinite(p.plane.w)); + return csg; + } + toPolygons() { + return this.polygons; + } + union(csg) { + const a = new Node(this.clone().polygons); + const b = new Node(csg.clone().polygons); + a.clipTo(b); + b.clipTo(a); + b.invert(); + b.clipTo(a); + b.invert(); + a.build(b.allPolygons()); + return _CSG.fromPolygons(a.allPolygons()); + } + subtract(csg) { + const a = new Node(this.clone().polygons); + const b = new Node(csg.clone().polygons); + a.invert(); + a.clipTo(b); + b.clipTo(a); + b.invert(); + b.clipTo(a); + b.invert(); + a.build(b.allPolygons()); + a.invert(); + return _CSG.fromPolygons(a.allPolygons()); + } + intersect(csg) { + const a = new Node(this.clone().polygons); + const b = new Node(csg.clone().polygons); + a.invert(); + b.clipTo(a); + b.invert(); + a.clipTo(b); + b.clipTo(a); + a.build(b.allPolygons()); + a.invert(); + return _CSG.fromPolygons(a.allPolygons()); + } + // Return a new CSG solid with solid and empty space switched. This solid is + // not modified. + inverse() { + const csg = this.clone(); + for (const p of csg.polygons) { + p.flip(); + } + return csg; + } + toMesh(toMatrix, toMaterial) { + return _CSG.toMesh(this, toMatrix, toMaterial); + } + toGeometry(toMatrix) { + return _CSG.toGeometry(this, toMatrix); + } + }; + + // entry.js + window.CSG = CSG; +})();