diff --git a/src/bonsai/bonsai/bim/data/webui/sioserver.py b/src/bonsai/bonsai/bim/data/webui/sioserver.py
index d14148520a..55a1b73f29 100644
--- a/src/bonsai/bonsai/bim/data/webui/sioserver.py
+++ b/src/bonsai/bonsai/bim/data/webui/sioserver.py
@@ -194,6 +194,11 @@ class BlenderNamespace(socketio.AsyncNamespace):
blender_messages[sid]["classification"] = data
await sio.emit("classification", {"blenderId": sid, "data": data}, namespace="/web")
+ async def on_message(self, sid, data):
+ print(f"Error from Blender client {sid}")
+ blender_messages[sid]["error"] = data
+ await sio.emit("message", {"blenderId": sid, "data": data}, namespace="/web")
+
async def schedules(request):
with open("templates/index.html", "r") as f:
diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/cost.css b/src/bonsai/bonsai/bim/data/webui/static/css/cost.css
index 71a973fcac..595b5b0800 100644
--- a/src/bonsai/bonsai/bim/data/webui/static/css/cost.css
+++ b/src/bonsai/bonsai/bim/data/webui/static/css/cost.css
@@ -7,8 +7,8 @@
--fontSize: 14px;
--background-color: #363636;
--button-background: #28a745;
- --button-hover-background: #218838;
- --highlight-over: #ED5700;
+ --button-hover-background: #ed5700;
+ --highlight-over: #ed5700;
--scrollbar-track-color: #f1f1f1;
--scrollbar-thumb-color: #888;
--scrollbar-thumb-hover-color: #555;
@@ -77,13 +77,16 @@ body {
text-align: left;
}
-
-.floating-form table tr {
+.floating-form tr {
margin-bottom: 10px;
}
+.floating-form td {
+ padding: 3px;
+}
+
.action-button {
- position:relative;
+ position: relative;
background-color: var(--button-background);
border: none;
color: #fff;
@@ -120,8 +123,7 @@ body {
}
.active-btn {
- background-color: #887821;
- box-shadow: 0 0 10px #887821;
+ background-color: var(--highlight-over);
}
.action-button:hover {
@@ -166,17 +168,6 @@ body {
background: var(--scrollbar-thumb-hover-color);
}
-/* Firefox */
-* {
- scrollbar-width: thin;
- scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color);
-}
-
-/* Edge, IE */
-* {
- -ms-overflow-style: -ms-autohiding-scrollbar;
-}
-
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -192,12 +183,12 @@ body {
flex: 1;
overflow: auto;
border: 2px solid #254e3c;
- box-shadow: 0 -5px 5px -0 #254e3c;
}
.floating-form .table-container {
max-height: 80%;
overflow: auto;
+ box-shadow: 0 5px 5px 0 #254e3c;
}
#cost-items {
@@ -213,8 +204,10 @@ body {
height: 50vh;
}
-tr, td, th {
- height: 100%;
+tr,
+td,
+th {
+ height: 100%;
}
table {
@@ -224,7 +217,6 @@ table {
font-size: var(--fontSize);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
- border: 1px solid #ddd;
}
thead {
@@ -291,7 +283,7 @@ input:focus {
color: #ddd;
}
-#cost-items input{
+#cost-items input {
border-radius: 6px;
padding: 6px 8px;
font-size: 14px;
@@ -300,7 +292,8 @@ input:focus {
min-width: 50px;
}
-form input, form select {
+form input,
+form select {
border-radius: 6px;
padding: 3px 4px;
font-size: 12px;
@@ -309,7 +302,7 @@ form input, form select {
height: 100%;
max-height: 30px;
max-width: 100%;
- box-sizing: border-box;
+ box-sizing: border-box;
}
#cost-items tr:hover,
@@ -438,7 +431,6 @@ i {
.selectedClassificationText {
color: #28a745;
-
}
.back-button {
@@ -450,8 +442,7 @@ i {
background-color: #ccc;
}
-
-.classification-cell{
+.classification-cell {
display: flex;
flex-direction: column;
justify-content: center;
@@ -459,7 +450,7 @@ i {
height: 100%;
}
-.classification{
+.classification {
height: 100%;
}
@@ -495,4 +486,78 @@ i {
#column-selector label:last-child {
margin-bottom: 0;
-}
\ No newline at end of file
+}
+
+.switch-bar {
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ padding: 10px 0;
+}
+
+.switch-bar .action-button {
+ border: 1px solid #000000;
+ border-radius: 5px 5px 0 0;
+ margin: 0;
+ margin-left: -5px;
+}
+
+.message {
+ position: relative;
+ padding: 10px;
+ border-radius: 5px;
+ margin: 10px 0;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+}
+
+.message span {
+ width: 80%;
+}
+
+.message .icon {
+ margin-right: 10px;
+}
+
+.message .close-button {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ transition: background-color 0.3s, color 0.3s;
+ padding-left: 5px;
+}
+
+.message .close-button:hover {
+ background-color: #ffcccc;
+}
+
+.error {
+ color: red;
+ border: 1px solid red;
+ background-color: #ffe6e6;
+}
+
+.error .close-button {
+ color: red;
+}
+
+.error .close-button:hover {
+ color: darkred;
+}
+
+.success {
+ color: green;
+ border: 1px solid green;
+ background-color: #e6ffe6;
+}
+
+.success .close-button {
+ color: green;
+}
+
+.success .close-button:hover {
+ background-color: #ccffcc;
+ color: darkgreen;
+}
diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/cost.js b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js
index e396ebb88c..1666bdadb8 100644
--- a/src/bonsai/bonsai/bim/data/webui/static/js/cost.js
+++ b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js
@@ -30,6 +30,7 @@ function connectSocket() {
socket.on("cost_value", handleCostValueData);
socket.on("quantities", handleEditQuantities);
socket.on("classification", handleClassificationData);
+ socket.on("message", handleMessage);
}
function handleEditQuantities(data) {
@@ -51,6 +52,7 @@ function handleEditQuantities(data) {
addQuantity: addQuantity,
editQuantity: editQuantity,
deleteQuantity: deleteQuantity,
+ assignFromQuery: assignFromQuery,
},
});
}
@@ -59,6 +61,15 @@ function addSumCostValue(costItemId) {
executeOperator({ type: "addSumCostValue", costItemId: costItemId });
}
+function assignFromQuery(costItemId, query, propName) {
+ executeOperator({
+ type: "assignFromQuery",
+ costItemId: costItemId,
+ query: query,
+ propName: propName,
+ });
+}
+
function addQuantity(costItemId, ifcClass) {
executeOperator({
type: "AddCostItemQuantity",
@@ -461,6 +472,28 @@ function deleteCostItem(costItemId) {
executeOperator({ type: "deleteCostItem", costItemId: costItemId });
}
+function handleMessage(data) {
+ if (data.data["message"].hasOwnProperty("error")) {
+ handleError(data);
+ } else {
+ handleSuccess(data);
+ }
+}
+
+function handleError(data) {
+ const errorMessage = data.data["message"]["error"];
+ const containerId = data.data["message"]["container"];
+ const container = document.getElementById(containerId);
+ CostUI.Error(container, errorMessage);
+}
+
+function handleSuccess(data) {
+ const successMessage = data.data["message"]["success"];
+ const containerId = data.data["message"]["container"];
+ const container = document.getElementById(containerId);
+ CostUI.Success(container, successMessage);
+}
+
function executeOperator(operator, blenderId) {
const msg = {
sourcePage: "cost",
diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js
index a9deaa1b04..8663207685 100644
--- a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js
+++ b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js
@@ -194,17 +194,6 @@ export class CostUI {
});
}
- static createRibbonBar() {
- const ribbonBar = document.createElement("div");
- ribbonBar.className = "switch-bar";
- ribbonBar.innerHTML = `
-
-
-
- `;
- return ribbonBar;
- }
-
static createRibbon() {
CostUI.addSettingsMenu();
CostUI.addRibbonButton({
@@ -1000,6 +989,42 @@ export class CostUI {
return div;
}
+ static Error(container, message) {
+ const existingError = container.querySelector(".error");
+ existingError ? existingError.remove() : null;
+
+ const error = this.Text(
+ message,
+ "fa-solid fa-exclamation-triangle",
+ "large"
+ );
+ error.classList.add("error", "message");
+
+ const closeButton = this.createCloseButton(() => {
+ error.remove();
+ });
+
+ error.appendChild(closeButton);
+ container.appendChild(error);
+ return error;
+ }
+
+ static Success(container, message) {
+ const existingSuccess = container.querySelector(".success");
+ existingSuccess ? existingSuccess.remove() : null;
+
+ const success = this.Text(message, "fa-solid fa-check-circle", "large");
+ success.classList.add("success", "message");
+
+ const closeButton = this.createCloseButton(() => {
+ success.remove();
+ });
+
+ success.appendChild(closeButton);
+ container.appendChild(success);
+ return success;
+ }
+
static createCard(id, title, mainContainer, callback) {
const card = document.createElement("div");
card.classList.add("card");
@@ -2077,6 +2102,35 @@ export class CostUI {
}
}
+ static createRibbonBar() {
+ const ribbonBar = document.createElement("div");
+ ribbonBar.className = "switch-bar";
+ const buttons = [
+ {
+ text: "Parametric Take Off",
+ id: "selected-products",
+ },
+ {
+ text: "Review Assignments",
+ id: "assigned-products",
+ },
+ {
+ text: "Manual Take Off",
+ id: "manual-quantities",
+ },
+ {
+ text: "Query Product qto",
+ id: "query-quantities",
+ },
+ ];
+ buttons.forEach((button) => {
+ const actionButton = CostUI.createButton(button.text);
+ actionButton.id = button.id + "-btn";
+ ribbonBar.appendChild(actionButton);
+ });
+ return ribbonBar;
+ }
+
static enableEditingQuantities({
costItemId,
selectedProducts,
@@ -2116,9 +2170,15 @@ export class CostUI {
callbacks,
});
+ const QuantityQuerySection = CostUI.QuantityQuerySection({
+ costItemId,
+ callbacks,
+ });
+
form.appendChild(selectedProductsSection);
form.appendChild(assignedProductsSection);
form.appendChild(manualQuantitiesSection);
+ form.appendChild(QuantityQuerySection);
const summarySection = CostUI.createSummarySection({
selectedProducts,
@@ -2133,6 +2193,7 @@ export class CostUI {
selectedProductsSection,
assignedProductsSection,
manualQuantitiesSection,
+ QuantityQuerySection,
});
const lastActiveSection =
@@ -2207,6 +2268,7 @@ export class CostUI {
selectedProductsSection,
assignedProductsSection,
manualQuantitiesSection,
+ QuantityQuerySection,
}) {
const buttons = switchBar.querySelectorAll(".action-button");
@@ -2222,6 +2284,7 @@ export class CostUI {
assignedProductsSection.style.display = "none";
if (manualQuantitiesSection)
manualQuantitiesSection.style.display = "none";
+ if (QuantityQuerySection) QuantityQuerySection.style.display = "none";
const section = document.getElementById(sectionId);
if (section) {
@@ -2255,6 +2318,13 @@ export class CostUI {
);
selectedProductsSection.appendChild(numberOfProducts);
+ const refreshButton = CostUI.createButton("Refresh", "fa-solid fa-sync");
+ refreshButton.addEventListener("click", function (e) {
+ e.preventDefault();
+ callbacks.enableEditingQuantities(costItemId);
+ });
+ selectedProductsSection.appendChild(refreshButton);
+
const selectedProductsTable = CostUI.createProductTable({
container: selectedProductsSection,
products: products,
@@ -2389,9 +2459,50 @@ export class CostUI {
manualQuantitiesSection.appendChild(tableContainer);
manualQuantitiesSection.appendChild(addButton);
+
return manualQuantitiesSection;
}
+ static QuantityQuerySection({ callbacks, costItemId }) {
+ const manualQuantityQuerySection = document.createElement("div");
+ manualQuantityQuerySection.classList.add("form-section");
+ manualQuantityQuerySection.id = "query-quantities-section-" + costItemId;
+ manualQuantityQuerySection.style.display = "none";
+
+ const title = CostUI.Text(
+ "Manual Quantities",
+ "fa-solid fa-ruler",
+ "medium"
+ );
+ manualQuantityQuerySection.appendChild(title);
+
+ const queryInput = document.createElement("input");
+ queryInput.type = "text";
+ queryInput.placeholder = "Enter a query";
+ queryInput.id = "query-input-" + costItemId;
+ manualQuantityQuerySection.appendChild(queryInput);
+
+ const propNameInput = document.createElement("input");
+ propNameInput.type = "text";
+ propNameInput.placeholder = "Enter a property name";
+ propNameInput.id = "prop-name-input-" + costItemId;
+ manualQuantityQuerySection.appendChild(propNameInput);
+
+ const queryButton = CostUI.createButton(
+ "Assign from Query",
+ "fa-solid fa-plus"
+ );
+ queryButton.addEventListener("click", function (e) {
+ e.preventDefault();
+ const query = queryInput.value;
+ const propName = propNameInput.value;
+ callbacks.assignFromQuery(costItemId, query, propName);
+ });
+
+ manualQuantityQuerySection.appendChild(queryButton);
+ return manualQuantityQuerySection;
+ }
+
static createManualQuantityRow(costItemId, quantity, callbacks) {
const tr = document.createElement("tr");
tr.id = quantity.id;
@@ -2458,20 +2569,26 @@ export class CostUI {
addButton.addEventListener("click", function (e) {
e.preventDefault();
- let type = quantityType;
- if (!type) {
- const qtoSection = document.getElementById(
- "manual-quantities-section-" + costItemId
- );
- const dropdown = qtoSection.querySelector("select");
- type = dropdown ? dropdown.value : null;
- }
+ let type = CostUI.getQuantityTypeAttribute(quantityType);
callbacks.addQuantity(costItemId, type);
});
return addButton;
}
+ static getQuantityTypeAttribute(quantityType) {
+ let type = quantityType;
+ if (!type) {
+ const qtoSection = document.getElementById(
+ "manual-quantities-section-" + costItemId
+ );
+ if (!qtoSection) return null;
+ const dropdown = qtoSection.querySelector("select");
+ type = dropdown ? dropdown.value : null;
+ }
+ return type;
+ }
+
static createCostClassificationWindow({
classificationName,
classificationElements,
diff --git a/src/bonsai/bonsai/tool/web.py b/src/bonsai/bonsai/tool/web.py
index c398b20215..221645ab9d 100644
--- a/src/bonsai/bonsai/tool/web.py
+++ b/src/bonsai/bonsai/tool/web.py
@@ -16,11 +16,12 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
-from ifcopenshell.util.classification import get_classification_data, get_references
+from ifcopenshell.util.classification import get_classification_data, get_references
+from ifcopenshell.util.selector import filter_elements
import ifcopenshell.util.cost
import bpy
from bonsai.bim.module.web.data import WebData
-from ifcopenshell.util.element import get_psets, get_type
+from ifcopenshell.util.element import get_psets, get_type, has_property
import bonsai.core.tool
import bonsai.tool as tool
import ifcopenshell.api.sequence
@@ -510,11 +511,11 @@ class Web(bonsai.core.tool.Web):
cost_classifications.append(cost_classification_data)
classification_data, classification_name = get_classification_data(IfcStore.classification_file)
data = {
- "classification_data": classification_data,
- "classification_name": classification_name,
- "cost_item_id": cost_item.id(),
- "cost_classifications": cost_classifications,
- }
+ "classification_data": classification_data,
+ "classification_name": classification_name,
+ "cost_item_id": cost_item.id(),
+ "cost_classifications": cost_classifications,
+ }
cls.send_webui_data(
data=data,
data_key="classification",
@@ -523,7 +524,7 @@ class Web(bonsai.core.tool.Web):
if operator_data["type"] == "removeClassificationReference":
cost_item = ifc_file.by_id(operator_data["costItemId"])
cost_schedule = tool.Cost.get_cost_schedule(cost_item)
- reference = ifc_file.by_id(operator_data["classificationId"])
+ reference = ifc_file.by_id(operator_data["classificationId"])
tool.Ifc.run("classification.remove_reference", products=[cost_item], reference=reference)
cls.load_cost_schedule_web_ui(cost_schedule)
if operator_data["type"] == "addClassificationReference":
@@ -536,8 +537,63 @@ class Web(bonsai.core.tool.Web):
classification = element
break
reference = IfcStore.classification_file.by_id(operator_data["classificationId"])
- tool.Ifc.run("classification.add_reference", products=[cost_item], reference=reference, classification=classification)
+ tool.Ifc.run(
+ "classification.add_reference", products=[cost_item], reference=reference, classification=classification
+ )
cls.load_cost_schedule_web_ui(cost_schedule)
+ if operator_data["type"] == "assignFromQuery":
+ prop_name = operator_data["propName"]
+ cost_item = ifc_file.by_id(operator_data["costItemId"])
+ results = []
+ try:
+ results = filter_elements(ifc_file, operator_data["query"])
+ except Exception as e:
+ cls.send_webui_data(
+ data={
+ "error": "Your query is invalid. Please check the syntax and try again.",
+ "container": "query-quantities-section-" + str(operator_data["costItemId"]),
+ },
+ data_key="message",
+ event="message",
+ )
+ return
+ if not results:
+ cls.send_webui_data(
+ data={
+ "error": "No elements found with the specified query. Are you sure the query is correct?",
+ "container": "query-quantities-section-" + str(operator_data["costItemId"]),
+ },
+ data_key="message",
+ event="message",
+ )
+ return
+ products_with_quantity = [r for r in results if has_property(r, prop_name)]
+ if products_with_quantity:
+ tool.Ifc.run(
+ "cost.assign_cost_item_quantity",
+ cost_item=cost_item,
+ products=results,
+ prop_name=prop_name,
+ )
+ cost_schedule = tool.Cost.get_cost_schedule(cost_item)
+ cls.load_cost_schedule_web_ui(cost_schedule)
+ cls.send_webui_data(
+ data={
+ "success": f"Successfully assigned quantity from {len(products_with_quantity)} products.",
+ "container": "query-quantities-section-" + str(operator_data["costItemId"]),
+ },
+ data_key="message",
+ event="message",
+ )
+ else:
+ cls.send_webui_data(
+ data={
+ "error": f"Found {len(results)} products, but none with the specified property '{prop_name}'.",
+ "container": "query-quantities-section-" + str(operator_data["costItemId"]),
+ },
+ data_key="message",
+ event="message",
+ )
@classmethod
def load_cost_item_quantities_ui(cls, cost_item: ifcopenshell.entity_instance) -> None:
diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py
index e59d2377da..0a4fe923e3 100644
--- a/src/ifcopenshell-python/ifcopenshell/util/element.py
+++ b/src/ifcopenshell-python/ifcopenshell/util/element.py
@@ -1630,3 +1630,26 @@ def copy_deep(
else:
new[i] = attribute
return new
+
+def has_property(product: ifcopenshell.entity_instance, property_name: str) -> bool:
+ """
+ Check if a product has a property with a given name.
+
+ :param product: The IFC product
+ :type product: ifcopenshell.entity_instance
+ :param property_name: The property name
+ :type property_name: str
+ :return: True if the product has the property, False otherwise
+ :rtype: bool
+
+ Example:
+
+ .. code:: python
+
+ product = file.by_type("IfcWall")[0]
+ has_property = ifcopenshell.util.element.has_property(product, "NetArea")
+ """
+ if not property_name:
+ return True
+ qtos = get_psets(product, qtos_only=True)
+ return any(property_name in quantities.keys() for quantities in qtos.values())
\ No newline at end of file