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;
+})();