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