diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index f77ffaa117..75d7fe5fa6 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -730,6 +730,10 @@ if(MSVC) # endif() add_definitions(-D_ENABLE_EXTENDED_ALIGNED_STORAGE) + # See #5158. + if(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 19.40) + add_definitions(-D_DISABLE_CONSTEXPR_MUTEX_CONSTRUCTOR) + endif() else() add_definitions(-Wall -Wextra) diff --git a/nix/build-all.py b/nix/build-all.py index 8f63612434..756b6b3c1b 100644 --- a/nix/build-all.py +++ b/nix/build-all.py @@ -966,7 +966,7 @@ if "IfcOpenShell-Python" in targets: logger.info(f"\rBuilding python {python_version} wrapper... ") - run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "_ifcopenshell_wrapper"], cwd=python_dir) + run([make, f"-j{IFCOS_NUM_BUILD_PROCS}", "ifcopenshell_wrapper"], cwd=python_dir) run([make, "install/local"], cwd=os.path.join(python_dir, "ifcwrap")) if python_executable: diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 524ac9f91c..8a67f787de 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -81,7 +81,7 @@ BLENDER_PLATFORM:=windows-x64 endif # Current build commit hash. -OLD:=03935a9 +OLD:=f5e02d1 .PHONY: bump bump: cd . && $(SED) -b "s/$(OLD)/$(NEW)/" Makefile diff --git a/src/bonsai/bonsai/bim/data/webui/sioserver.py b/src/bonsai/bonsai/bim/data/webui/sioserver.py index 91cec745e5..e80173c474 100644 --- a/src/bonsai/bonsai/bim/data/webui/sioserver.py +++ b/src/bonsai/bonsai/bim/data/webui/sioserver.py @@ -183,10 +183,10 @@ class BlenderNamespace(socketio.AsyncNamespace): blender_messages[sid]["predefined_types"] = data await sio.emit("predefined_types", {"blenderId": sid, "data": data}, namespace="/web") - async def on_selected_products(self, sid, data): + async def on_quantities(self, sid, data): print(f"Selected products from Blender client {sid}") - blender_messages[sid]["selected_products"] = data - await sio.emit("selected_products", {"blenderId": sid, "data": data}, namespace="/web") + blender_messages[sid]["quantities"] = data + await sio.emit("quantities", {"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 bf2a85a9df..eac13d1945 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/css/cost.css +++ b/src/bonsai/bonsai/bim/data/webui/static/css/cost.css @@ -20,6 +20,22 @@ body { box-sizing: border-box; } +.floating-form { + width: 50vw; + max-height: 80vh; + position: absolute; + background-color: var(--background-color); + border: 1px solid black; + z-index: 9999; + cursor: move; + user-select: none; + padding-bottom: var(--padding-medium); +} + +[id^="enable-editing-quantities"].floating-form { + height: 70vh; +} + .form-header { display: flex; justify-content: flex-start; @@ -30,37 +46,44 @@ body { font-size: large; border-bottom: 1px solid #ddd; border-radius: 6px 6px 0 0; + height: 5%; } -.floating-form span { - padding: var(--padding-small); - margin-left: var(--margin-small); +.form-container { + height: 95%; + padding-left: var(--padding-medium); + padding-right: var(--padding-medium); } -.floating-form { - width: 50vw; - max-height: 80vh; - position: absolute; - background-color: var(--background-color); - border: 1px solid black; - z-index: 9999; - cursor: move; - user-select: none; - resize: both; +.form-section { + margin-bottom: 1em; + height: 65%; + overflow-y: auto; + overflow-x: hidden; } +.form-section h3 { + margin-bottom: 0.5em; +} + +.summary-section { + margin-top: 1em; +} + +.summary-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + text-align: left; +} + + .floating-form table tr { margin-bottom: 10px; } - -.form-container { - padding: var(--padding-medium); - overflow-y: auto; - overflow-x: hidden; -} - .action-button { + position:relative; background-color: var(--button-background); border: none; color: #fff; @@ -73,6 +96,32 @@ body { transition: background-color 0.3s ease; width: fit-content; margin: 0.5em; + z-index: 11000; +} + +.action-button::after { + content: attr(data-tooltip); + position: absolute; + bottom: -30px; + background-color: #333; + color: #fff; + padding: 5px 10px; + border-radius: 5px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + font-size: 12px; +} + +.action-button:hover::after { + opacity: 1; +} + +.active-btn { + background-color: #887821; + /* make a nic shadow */ + box-shadow: 0 0 10px #887821; } .action-button:hover { @@ -142,7 +191,7 @@ body { } .floating-form .table-container { - max-height: 70%; + max-height: 80%; } #cost-items { @@ -150,12 +199,10 @@ body { } [id^="cost-values-form"] table { - /* fixed */ max-height: 50%; table-layout: fixed; } - [id^="selected-products"] .form-container { height: 50vh; } @@ -186,9 +233,9 @@ tbody { td, th { - border-left: 1px solid #ddd; border-right: 1px solid #ddd; text-align: center; + height: 100%; } th { @@ -224,15 +271,7 @@ select { color: var(--blender-button-text, var(--text-color)); border-color: var(--blender-button-border, var(--border-color)); transition: filter 0.2s ease; - border-radius: 6px; - padding: 6px 8px; - font-size: 14px; - line-height: 20px; transition: border-color 0.2s cubic-bezier(0.3, 0, 0.5, 1); - margin-left: 0.5em; - margin-right: 0.5em; - font-size: var(--fontSize); - min-width: 50px; } input:focus { @@ -242,6 +281,31 @@ input:focus { color: #ddd; } +#cost-items input, #cost-items +select { + border-radius: 6px; + padding: 6px 8px; + font-size: 14px; + line-height: 20px; + margin-left: 0.5em; + margin-right: 0.5em; + font-size: var(--fontSize); + min-width: 50px; +} + +form input { + border-radius: 6px; + padding: 3px 4px; + font-size: 12px; + line-height: 10px; + margin-left: 0.2em; + margin-right: 0.2em; + font-size: inherit; + height: inherit; + width: inherit; + box-sizing: border-box; +} + #cost-items tr:hover, .highlighted { background-color: #28a74657; @@ -304,8 +368,38 @@ form table { max-height: 50%; } - .subtotal-row { background-color: var(--button-hover-background); font-weight: bold; +} + +[id^="cost-items"] th:nth-child(1), +[id^="cost-items"] td:nth-child(1) { + width: auto; +} +[id^="cost-items"] th:not(:nth-child(1)), +[id^="cost-items"] td:not(:nth-child(1)) { + width: 10%; +} + +[id^="cost-items"] th:last-child, +[id^="cost-items"] td:last-child { + width: fit-content; + min-width: fit-content; + max-width: 50%; +} + +.actions-column { + transition: opacity 0.3s ease-in-out; + opacity: 0; +} + +[id^="cost-items"] tr:hover .actions-column { + opacity: 1; +} + +.clickable-cell:hover { + border: 1px solid #94a728; + border-radius: 6px; + cursor: pointer; } \ No newline at end of file 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 22fca33d5a..be078a8d3c 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/cost.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js @@ -28,53 +28,57 @@ function connectSocket() { socket.on("cost_items", handleCostItemsData); socket.on("cost_values", handleCostValuesData); socket.on("cost_value", handleCostValueData); - socket.on("selected_products", handleSelectedProducts); + socket.on("quantities", handleEditQuantities); } -function handleSelectedProducts(data) { - const costItemId = data.data["selected_products"]["cost_item_id"]; - const products = data.data["selected_products"]["selected_products"]; - const assigned_products = data.data["selected_products"]["assigned_products"]; - const quantityNames = - data.data["selected_products"]["product_quantity_names"]; - const formId = "selected-products-" + costItemId; - const form = CostUI.Form({ - id: formId, - name: "Edit Product Assignments for: " + CostUI.getCostItemName(costItemId), - icon: "fa-solid fa-box", - }); - const numberOfProducts = CostUI.Text( - "Selection basket : " + products.length + " products", - "fa-solid fa-cart-shopping", - "large" - ); - form.appendChild(numberOfProducts); - CostUI.highlightElement(costItemId); - const selectedProductsTable = CostUI.createProductTable({ - form, - products, - quantityNames, - costItemId, +function handleEditQuantities(data) { + const costItemId = data.data["quantities"]["cost_item_id"]; + const products = data.data["quantities"]["selected_products"]; + const assigned_products = data.data["quantities"]["assigned_products"]; + const quantityNames = data.data["quantities"]["product_quantity_names"]; + const costQuantities = data.data["quantities"]["cost_quantities"]; + + CostUI.enableEditingQuantities({ + costItemId: costItemId, + selectedProducts: products, + assignedProducts: assigned_products, + quantityNames: quantityNames, + costQuantities: costQuantities, callbacks: { addProductAssignments: addProductAssignments, - getSelectedProducts: getSelectedProducts, + enableEditingQuantities: enableEditingQuantities, + addQuantity: addQuantity, + editQuantity: editQuantity, + deleteQuantity: deleteQuantity, }, }); +} - if (assigned_products.length > 0) { - const assignedProductsText = CostUI.Text( - "Assigned Products", - "fa-solid fa-solid fa-paperclip", - "large" - ); - form.appendChild(assignedProductsText); - const assignmentsTable = CostUI.createAssignmentsTable({ - form, - products: assigned_products, - quantityNames, - costItemId, - }); - } +function addQuantity(costItemId, ifcClass) { + executeOperator({ + type: "AddCostItemQuantity", + costItemId: costItemId, + ifcClass: ifcClass, + }); + + //CostUI.addQuantity(costItemId, quantityName); +} + +function editQuantity(costItemId, quantityId, attributes) { + executeOperator({ + type: "editCostItemQuantity", + costItemId: costItemId, + quantityId: quantityId, + attributes: attributes, + }); +} + +function deleteQuantity(costItemId, quantityId) { + executeOperator({ + type: "deleteCostItemQuantity", + costItemId: costItemId, + quantityId: quantityId, + }); } function handlePredefinedTypes(data) { @@ -322,9 +326,6 @@ function handleCostSchedulesData(data) { if (costSchedules.length === 0) { return; } - const currency = data.data["cost_schedules"]["currency"] - ? data.data["cost_schedules"]["currency"]["name"] - : "Undefined"; const costScheduleDiv = document.getElementById("cost-schedules"); costScheduleDiv.innerHTML = ""; costSchedules.forEach((costSchedule) => { @@ -368,8 +369,13 @@ function handleCostItemsData(data) { loadedSchedules[data.blenderId] = costScheduleId; CostUI.highlightElement("schedule-" + costScheduleId); + const currency = data.data["cost_items"]["currency"] + ? data.data["cost_items"]["currency"]["name"] + : "Undefined"; + CostUI.createCostSchedule({ - data: data.data["cost_items"]["cost_items"], + costItems: data.data["cost_items"]["cost_items"], + currency: currency, costScheduleId: costScheduleId, blenderID: data.blenderId, callbacks: { @@ -380,13 +386,13 @@ function handleCostItemsData(data) { editCostItemName: editCostItemName, enableEditingCostValues: enableEditingCostValues, addSummaryCostItem: addSummaryCostItem, - getSelectedProducts: getSelectedProducts, + enableEditingQuantities: enableEditingQuantities, }, }); } -function getSelectedProducts(costItemId) { - executeOperator({ type: "getSelectedProducts", costItemId: costItemId }); +function enableEditingQuantities(costItemId) { + executeOperator({ type: "enableEditingQuantities", costItemId: costItemId }); } function duplicateCostItem(costItemId) { 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 a3f6c76882..7a99b57c8a 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 @@ -9,7 +9,7 @@ export class CostUI { static removeCostSchedule(id) { document.getElementById("cost-items-" + id).remove(); } - static createTable(id, callbacks) { + static createCostTable(id, currency, callbacks) { CostUI.isCostScheduleLoaded(id) ? CostUI.removeCostSchedule(id) : null; const table = document.createElement("table"); @@ -21,9 +21,9 @@ export class CostUI { "Name", "Quantity", "Unit", - "Cost", - "Total Cost", - "Action", + "Cost (" + currency + ")", + "Total Cost (" + currency + ")", + "Actions", ]; const thead = document.createElement("thead"); const tr = document.createElement("tr"); @@ -47,7 +47,6 @@ export class CostUI { table.appendChild(tbody); document.getElementById("cost-items").appendChild(table); - CostUI.addTableStyles(id); CostUI.createContextMenu(callbacks); table.get_blender_id = function () { return this.getAttribute("id").split("-")[2]; @@ -56,20 +55,12 @@ export class CostUI { return [table, tbody]; } - static addTableStyles(id) { - const style = document.createElement("style"); - style.textContent = ` - #cost-items-${id} th:nth-child(1), - #cost-items-${id} td:nth-child(1) { - width: auto; - } - #cost-items-${id} th:not(:nth-child(1)), - #cost-items-${id} td:not(:nth-child(1)) { - width: 100px; /* Set a fixed width for other columns */ - } - - `; - document.head.appendChild(style); + static deleteCostItem(costItemId, callback) { + const costItemRow = document.getElementById(costItemId); + let expandedState = JSON.parse(localStorage.getItem("expandedState")) || {}; + expandedState = CostUI.deleteCostItemRow(costItemRow, expandedState); + localStorage.setItem("expandedState", JSON.stringify(expandedState)); + callback(costItemId); } static createContextMenu(callbacks) { @@ -139,21 +130,17 @@ export class CostUI { addButton.dataset.listenerAdded = "true"; } - const deleteCostItem = document.getElementById("delete-cost-item"); - if (deleteCostItem && !deleteCostItem.hasListener) { - deleteCostItem.addEventListener("click", function () { + const deleteCostItemButton = document.getElementById("delete-cost-item"); + if (deleteCostItemButton && !deleteCostItemButton.hasListener) { + deleteCostItemButton.addEventListener("click", function () { const targetRow = document.getElementById("context-menu").targetRow; if (targetRow) { const costItemId = parseInt(targetRow.getAttribute("id")); - let expandedState = - JSON.parse(localStorage.getItem("expandedState")) || {}; - expandedState = CostUI.deleteCostItemRow(targetRow, expandedState); - localStorage.setItem("expandedState", JSON.stringify(expandedState)); - callbacks.deleteCostItem(costItemId); + CostUI.deleteCostItem(costItemId, callbacks.deleteCostItem); } document.getElementById("context-menu").style.display = "none"; }); - deleteCostItem.hasListener = true; + deleteCostItemButton.hasListener = true; } const duplicateButton = document.getElementById("duplicate-button"); @@ -175,7 +162,7 @@ export class CostUI { const targetRow = document.getElementById("context-menu").targetRow; if (targetRow) { const costItemId = parseInt(targetRow.getAttribute("id")); - callbacks.getSelectedProducts(costItemId); + callbacks.enableEditingQuantities(costItemId); } document.getElementById("context-menu").style.display = "none"; }); @@ -192,7 +179,7 @@ export class CostUI { document .getElementById("cost-items") - .addEventListener("dblclick", function (event) { + .addEventListener("click", function (event) { const targetRow = event.target.closest("tr"); const targetCell = event.target.closest("td"); if (targetRow && targetCell) { @@ -202,11 +189,16 @@ export class CostUI { const costItemId = parseInt(targetRow.getAttribute("id")); const columnName = getColumnNames("cost-items")[columnIndex]; - if (columnName === "Cost") { + if (columnName.includes("Cost") && !columnName.includes("Total")) { callbacks.enableEditingCostValues ? callbacks.enableEditingCostValues(costItemId) : null; } + if (columnName === "Quantity") { + callbacks.enableEditingQuantities + ? callbacks.enableEditingQuantities(costItemId) + : null; + } } }); } @@ -363,13 +355,18 @@ export class CostUI { } static createCostSchedule({ - data, + costItems, + currency, costScheduleId, blenderID, callbacks = {}, }) { - const [table, tbody] = CostUI.createTable(blenderID, callbacks); - if (data.length === 0) { + const [table, tbody] = CostUI.createCostTable( + blenderID, + currency, + callbacks + ); + if (costItems.length === 0) { const tr = document.createElement("tr"); const td = document.createElement("td"); td.colSpan = 6; @@ -387,24 +384,29 @@ export class CostUI { td.appendChild(addSummaryCostItemButton); addSummaryCostItemButton.classList.add("action-button"); } else { - CostUI.createCostItem(data, tbody, 0, null, callbacks); + CostUI.createCostTree(costItems, tbody, 0, null, callbacks); CostUI.applyExpandedState(); } } - static createCostItem( - data, + static createCostTree( + costItems, container, nestingLevel = 0, parentID = null, callbacks = {} ) { - data.forEach((costItem) => { - const row = CostUI.createRow(costItem, nestingLevel, parentID, callbacks); + costItems.forEach((costItem) => { + const row = CostUI.addCostItemRow( + costItem, + nestingLevel, + parentID, + callbacks + ); container.appendChild(row); if (costItem.is_nested_by && costItem.is_nested_by.length > 0) { - CostUI.createCostItem( + CostUI.createCostTree( costItem.is_nested_by, container, nestingLevel + 1, @@ -415,7 +417,7 @@ export class CostUI { }); } - static createRow(costItem, nestingLevel, parentID, callbacks = {}) { + static addCostItemRow(costItem, nestingLevel, parentID, callbacks = {}) { const totalQuantity = costItem.TotalCostQuantity ? parseFloat(costItem.TotalCostQuantity).toFixed(2) : "-"; @@ -431,20 +433,21 @@ export class CostUI { callbacks ); const totalCostQuantityCell = CostUI.createTableCell(totalQuantity); + totalCostQuantityCell.classList.add("clickable-cell"); const unitSymbolCell = CostUI.createTableCell(costItem.UnitSymbol); const totalAppliedValueCell = CostUI.createTableCell(appliedValue); + totalAppliedValueCell.classList.add("clickable-cell"); const totalCostCell = CostUI.createTotalCostCell(costItem); - const flexContainerCell = CostUI.createFlexContainerCell( - costItem, - callbacks - ); + const actionsCell = CostUI.costItemActions(costItem, callbacks); + + actionsCell.classList.add("actions-column"); row.appendChild(nameCell); row.appendChild(totalCostQuantityCell); row.appendChild(unitSymbolCell); row.appendChild(totalAppliedValueCell); row.appendChild(totalCostCell); - row.appendChild(flexContainerCell); + row.appendChild(actionsCell); row.get_id = function () { return this.getAttribute("id"); @@ -636,30 +639,92 @@ export class CostUI { return totalCostCell; } - static createFlexContainerCell(costItem, callbacks) { + static costItemActions(costItem, callbacks) { const divFlex = document.createElement("div"); divFlex.classList.add("row-container"); - const selectButton = CostUI.createSelectButton(costItem, callbacks); - divFlex.appendChild(selectButton); + const addCostItem = CostUI.addCostItemButton( + costItem.id, + callbacks.addCostItem + ); + const selectButton = CostUI.createSelectButton( + costItem.id, + callbacks.selectAssignedElements + ); + const deleteButton = CostUI.deleteCostItemButton( + costItem.id, + callbacks.deleteCostItem + ); + const duplicateButton = CostUI.duplicateCostItemButton( + costItem.id, + callbacks.duplicateCostItem + ); - const flexContainerCell = document.createElement("td"); - flexContainerCell.appendChild(divFlex); - return flexContainerCell; + [addCostItem, duplicateButton, selectButton, deleteButton].forEach( + (button) => { + divFlex.appendChild(button); + } + ); + + const actionsCell = document.createElement("td"); + actionsCell.appendChild(divFlex); + return actionsCell; } - static createSelectButton(costItem, callbacks) { - const selectButton = document.createElement("button"); - selectButton.classList.add("action-button"); - selectButton.textContent = "Select"; - selectButton.addEventListener("click", function (e) { - e.stopPropagation(); - callbacks.selectAssignedElements - ? callbacks.selectAssignedElements(costItem.id) - : null; - }); + static duplicateCostItemButton(costItemId, duplicateCostItem) { + const duplicateButton = CostUI.createButton( + "Duplicate", + "fa-solid fa-copy" + ); + duplicateButton.addEventListener( + "click", + duplicateCostItem.bind(null, costItemId) + ); + return duplicateButton; + } + + static addCostItemButton(costItemId, addCostItem) { + const addCostItemButton = CostUI.createButton( + "Add Sub-Cost", + "fa-solid fa-plus" + ); + addCostItemButton.addEventListener( + "click", + addCostItem.bind(null, costItemId) + ); + return addCostItemButton; + } + + static createSelectButton(costItemId, selectAssignedElements) { + const selectButton = CostUI.createButton( + "Select", + "fa-solid fa-arrow-pointer" + ); + selectButton.addEventListener( + "click", + selectAssignedElements.bind(null, costItemId) + ); return selectButton; } + static deleteCostItemButton(costItemId, deleteCostItem) { + const deleteButton = CostUI.createButton("Delete", "fa-solid fa-trash"); + deleteButton.addEventListener( + "click", + CostUI.deleteCostItem.bind(null, costItemId, deleteCostItem) + ); + return deleteButton; + } + + static createButton(text, icon) { + const button = document.createElement("button"); + !icon ? (button.textContent = text) : null; + icon + ? button.classList.add("action-button", ...icon.split(" ")) + : button.classList.add("action-button"); + button.dataset.tooltip = text; + return button; + } + static highlightElement(id) { const element = document.getElementById(id); if (element) { @@ -728,11 +793,15 @@ export class CostUI { } static getRowNameCell(costItemId) { - return document.getElementById(costItemId).querySelector("td input"); + return document.getElementById(costItemId) + ? document.getElementById(costItemId).querySelector("td input") + : null; } static getCostItemName(costItemId) { - return CostUI.getRowNameCell(costItemId).value; + return CostUI.getRowNameCell(costItemId) + ? CostUI.getRowNameCell(costItemId).value + : "Unnamed"; } static createCostValuesForm({ costItemId, costValues, callbacks }) { @@ -775,7 +844,6 @@ export class CostUI { static addNewCostValueRow(costItemId, costValueId, costValueCallbacks) { const table = CostUI.getCostValuesTable(costItemId); if (!table) { - console.log("Cost values table not found for cost item ID:", costItemId); return; } const tr = CostUI.createCostvaluesRow( @@ -843,8 +911,8 @@ export class CostUI { } else if (costValue.applied_value) { costType = "FIXED"; } - - const typeCell = CostUI.createTableDropdown("type", costType); + const options = ["FIXED", "CATEGORY", "SUM"]; + const typeCell = CostUI.createTableDropdown("type", options, costType); const dropdown = typeCell.querySelector("select"); dropdown.addEventListener("change", function () { const selectedType = this.value; @@ -947,12 +1015,11 @@ export class CostUI { } } - static createTableDropdown(name, value = "FIXED") { + static createTableDropdown(name, options, value = "FIXED") { const cell = document.createElement("td"); const dropdown = document.createElement("select"); dropdown.name = name; - const options = ["FIXED", "CATEGORY", "SUM"]; options.forEach((optionValue) => { const option = document.createElement("option"); option.value = optionValue; @@ -1241,7 +1308,7 @@ export class CostUI { } static createProductTable({ - form, + container, products, quantityNames, costItemId, @@ -1251,7 +1318,7 @@ export class CostUI { const noProductsMessage = document.createElement("p"); noProductsMessage.textContent = "Your Blender Selection is empty! Select objects first."; - form.appendChild(noProductsMessage); + container.appendChild(noProductsMessage); return; } const { tableContainer, table } = CostUI.addTable({ @@ -1259,7 +1326,7 @@ export class CostUI { className: "", id: "cost-values-table-" + costItemId, }); - form.appendChild(tableContainer); + container.appendChild(tableContainer); const tbody = document.createElement("tbody"); table.appendChild(tbody); @@ -1367,7 +1434,7 @@ export class CostUI { const productIds = products.map((product) => product.info.id); callbacks.emptyForm = () => { - form.innerHTML = ""; + container.innerHTML = ""; }; const addProductAssignmentsButton = CostUI.addProductAssignmentsButton( costItemId, @@ -1376,13 +1443,13 @@ export class CostUI { callbacks ); - form.appendChild(quantitySelect); - form.appendChild(addProductAssignmentsButton); + container.appendChild(quantitySelect); + container.appendChild(addProductAssignmentsButton); return table; } static createAssignmentsTable({ - form, + container, products, costItemId, quantityNames, @@ -1394,7 +1461,7 @@ export class CostUI { id: "cost-values-table-" + costItemId, }); - form.appendChild(tableContainer); + container.appendChild(tableContainer); const tbody = document.createElement("tbody"); table.appendChild(tbody); @@ -1447,8 +1514,10 @@ export class CostUI { quantitySelect, callbacks ) { - const addProductAssignmentsButton = document.createElement("button"); - addProductAssignmentsButton.textContent = "Add Product Assignments"; + const addProductAssignmentsButton = CostUI.createButton( + "Add Product Assignments", + "fa-solid fa-plus" + ); addProductAssignmentsButton.addEventListener("click", (event) => { event.preventDefault(); let propName = quantitySelect.value; @@ -1463,7 +1532,7 @@ export class CostUI { }) : null; callbacks.emptyForm ? callbacks.emptyForm() : null; - callbacks.getSelectedProducts(costItemId); + callbacks.enableEditingQuantities(costItemId); }); addProductAssignmentsButton.classList.add("action-button"); return addProductAssignmentsButton; @@ -1479,6 +1548,7 @@ export class CostUI { const picker = CostUI.createColorPicker(); const tableFontSize = CostUI.createFontSizePicker(); + const currencyPicker = CostUI.createCurrencyPicker(); settingsMenu.appendChild(picker); settingsMenu.appendChild(tableFontSize); document.getElementById("settings-menu").style.display = "none"; @@ -1521,6 +1591,32 @@ export class CostUI { return div; } + static createCurrencyPicker() { + const currency = CostUI.getCurrency(); + const div = document.createElement("div"); + const currencyText = document.createElement("p"); + currencyText.textContent = "Select a currency for the table:"; + div.appendChild(currencyText); + const currencyPicker = document.createElement("input"); + currencyPicker.type = "text"; + currencyPicker.id = "currency-picker"; + currencyPicker.value = currency; + div.appendChild(currencyPicker); + currencyPicker.addEventListener("input", function () { + const currency = currencyPicker.value; + CostUI.saveCurrency(currency); + }); + return div; + } + + static saveCurrency(currency) { + localStorage.setItem("tableCurrency", currency); + } + + static getCurrency() { + return localStorage.getItem("tableCurrency"); + } + static saveFontSize(fontSize) { localStorage.setItem("tableFontSize", fontSize); } @@ -1637,4 +1733,425 @@ export class CostUI { }, }); } + + static enableEditingQuantities({ + costItemId, + selectedProducts, + assignedProducts, + quantityNames, + costQuantities, + callbacks, + }) { + CostUI.highlightElement(costItemId); + const form = CostUI.Form({ + id: "enable-editing-quantities-" + costItemId, + name: "Editing Quantities for: " + CostUI.getCostItemName(costItemId), + icon: "fa-solid fa-box", + }); + + const ribbonBar = CostUI.createRibbonBar(); + form.appendChild(ribbonBar); + + const selectedProductsSection = CostUI.selectedProductsSection({ + products: selectedProducts, + quantityNames, + costItemId, + callbacks, + }); + + const assignedProductsSection = CostUI.assignedProductsSection({ + products: assignedProducts, + quantityNames, + costItemId, + callbacks, + }); + + const manualQuantitiesSection = CostUI.manualQuantitiesSection({ + costItemId, + costQuantities, + has_assigned_products: assignedProducts.length > 0, + callbacks, + }); + + form.appendChild(selectedProductsSection); + form.appendChild(assignedProductsSection); + form.appendChild(manualQuantitiesSection); + + const summarySection = CostUI.createSummarySection({ + selectedProducts, + assignedProducts, + costQuantities, + }); + form.appendChild(summarySection); + + CostUI.addEventListeners({ + switchBar: ribbonBar, + costItemId, + selectedProductsSection, + assignedProductsSection, + manualQuantitiesSection, + }); + + const lastActiveSection = + localStorage.getItem("lastActiveSection") || "selected-products"; + const lastActiveButton = document.getElementById( + `${lastActiveSection}-btn` + ); + if (lastActiveButton) { + lastActiveButton.click(); + } + } + + static createRibbonBar() { + const ribbonBar = document.createElement("div"); + ribbonBar.className = "switch-bar"; + ribbonBar.innerHTML = ` + + + + `; + return ribbonBar; + } + + static createSummarySection({ + selectedProducts, + assignedProducts, + costQuantities, + }) { + const summarySection = document.createElement("div"); + summarySection.classList.add("summary-section"); + + const manualQuantities = costQuantities.quantities.filter( + (quantity) => !quantity.fromProduct + ); + const paramaterQuantities = costQuantities.quantities.filter( + (quantity) => quantity.fromProduct + ); + + const table = document.createElement("table"); + table.classList.add("summary-table"); + + const thead = document.createElement("thead"); + const headerRow = document.createElement("tr"); + const headers = ["Category", "Count"]; + headers.forEach((headerText) => { + const th = document.createElement("th"); + th.textContent = headerText; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + const tbody = document.createElement("tbody"); + + const data = [ + { category: "Assigned Products", count: assignedProducts.length }, + { category: "Manual Quantities", count: manualQuantities.length }, + { + category: "Product derived Quantities", + count: paramaterQuantities.length, + }, + ]; + + data.forEach((item) => { + const row = document.createElement("tr"); + const categoryCell = document.createElement("td"); + categoryCell.textContent = item.category; + const countCell = document.createElement("td"); + countCell.textContent = item.count; + row.appendChild(categoryCell); + row.appendChild(countCell); + tbody.appendChild(row); + }); + + table.appendChild(tbody); + summarySection.appendChild(table); + + return summarySection; + } + + static addEventListeners({ + switchBar, + costItemId, + selectedProductsSection, + assignedProductsSection, + manualQuantitiesSection, + }) { + const buttons = switchBar.querySelectorAll(".action-button"); + + buttons.forEach((button) => { + button.addEventListener("click", (e) => { + e.preventDefault(); + const sectionName = button.id.replace("-btn", ""); + const sectionId = sectionName + "-section-" + costItemId; + + if (selectedProductsSection) + selectedProductsSection.style.display = "none"; + if (assignedProductsSection) + assignedProductsSection.style.display = "none"; + if (manualQuantitiesSection) + manualQuantitiesSection.style.display = "none"; + + const section = document.getElementById(sectionId); + if (section) { + section.style.display = "block"; + } + buttons.forEach((btn) => btn.classList.remove("active-btn")); + button.classList.add("active-btn"); + + localStorage.setItem("lastActiveSection", sectionName); + }); + }); + } + + static getSection(costItemId, sectionName) { + return document.getElementById(`${sectionName}-${costItemId}`); + } + + static selectedProductsSection({ + products, + quantityNames, + costItemId, + callbacks, + }) { + const selectedProductsSection = document.createElement("div"); + selectedProductsSection.id = "selected-products-section-" + costItemId; + selectedProductsSection.classList.add("form-section"); + const numberOfProducts = CostUI.Text( + "Selection basket : " + products.length + " products", + "fa-solid fa-cart-shopping", + "medium" + ); + selectedProductsSection.appendChild(numberOfProducts); + + const selectedProductsTable = CostUI.createProductTable({ + container: selectedProductsSection, + products: products, + quantityNames, + costItemId, + callbacks, + }); + return selectedProductsSection; + } + + static assignedProductsSection({ + products, + quantityNames, + costItemId, + callbacks, + }) { + const assignedProductsSection = document.createElement("div"); + assignedProductsSection.id = "assigned-products-section-" + costItemId; + assignedProductsSection.style.display = "none"; + assignedProductsSection.classList.add("form-section"); + if (products.length > 0) { + let text = " Assigned Products : " + products.length; + const assignedProductsText = CostUI.Text( + text, + "fa-solid fa-solid fa-paperclip", + "medium" + ); + assignedProductsSection.appendChild(assignedProductsText); + const assignmentsTable = CostUI.createAssignmentsTable({ + container: assignedProductsSection, + products: products, + quantityNames, + costItemId, + callbacks, + }); + } + + return assignedProductsSection; + } + + static manualQuantitiesSection({ + costItemId, + costQuantities, + has_assigned_products, + callbacks, + }) { + const manualQuantitiesSection = document.createElement("div"); + manualQuantitiesSection.classList.add("form-section"); + manualQuantitiesSection.id = "manual-quantities-section-" + costItemId; + manualQuantitiesSection.style.display = "none"; + + const title = CostUI.Text( + "Manual Quantities", + "fa-solid fa-ruler", + "medium" + ); + manualQuantitiesSection.appendChild(title); + const unitSymbol = costQuantities.unit_symbol; + + let quantityType = costQuantities.quantity_type; + + if (quantityType) { + const text = CostUI.Text( + quantityType + " (" + unitSymbol + " )", + "fa-solid fa-ruler", + "small" + ); + manualQuantitiesSection.appendChild(text); + } else { + // add dropdown to chose quantity type , from "IfcQuantityArea", "IfcQuantityLength", "IfcQuantityVolume", "IfcQuantityCount", "IfcQuantityWeight " + const quantityTypes = [ + "IfcQuantityArea", + "IfcQuantityLength", + "IfcQuantityVolume", + "IfcQuantityCount", + "IfcQuantityWeight", + ]; + const dropdown = CostUI.createTableDropdown( + "type", + quantityTypes, + "IfcQuantityArea" + ); + dropdown.id = "quantity-type-dropdown-" + costItemId; + manualQuantitiesSection.appendChild(dropdown); + } + + // if quantity is ty IfcQuantityCount, and the costQuantities + if (quantityType === "IfcQuantityCount" && has_assigned_products) { + // write text that one of the quantities is assigned to the product selection + const text = CostUI.Text( + " One of the quantities is assigned to the product selection", + "fa-solid fa-warning", + "small" + ); + manualQuantitiesSection.appendChild(text); + } + + let paramaterQuantities = costQuantities.quantities.filter( + (quantity) => quantity.fromProduct + ); + let manualQuantities = costQuantities.quantities.filter( + (quantity) => !quantity.fromProduct + ); + + let headerNames = ["Name", "Value" + " (" + unitSymbol + " )", "Actions"]; + + if (manualQuantities.length > 0) { + headerNames = []; + Object.entries(manualQuantities[0]).forEach(([key, value]) => { + if (key !== "id" && key !== "fromProduct") { + headerNames.push(key); + } + }); + headerNames.push("Actions"); + } + + const { tableContainer, table } = CostUI.addTable({ + headers: headerNames, + className: "manual-quantities-table", + id: "manual-quantities-table-" + costItemId, + }); + + manualQuantities.forEach((quantity) => { + const tr = CostUI.createManualQuantityRow( + costItemId, + quantity, + callbacks + ); + table.appendChild(tr); + }); + const addButton = CostUI.createAddManualQuantityButton( + costItemId, + quantityType, + callbacks + ); + + manualQuantitiesSection.appendChild(tableContainer); + // manualQuantitiesSection.appendChild(tableContainer2); + manualQuantitiesSection.appendChild(addButton); + return manualQuantitiesSection; + } + + static createManualQuantityRow(costItemId, quantity, callbacks) { + const tr = document.createElement("tr"); + tr.id = quantity.id; + + // get quantity keys and values to create the table row cells + // Label cell + + Object.entries(quantity).forEach(([key, value]) => { + if (key !== "id" && key !== "fromProduct") { + if (key === "Name") { + // create input + const cell = document.createElement("td"); + const input = document.createElement("input"); + input.type = "text"; + input.value = value; + cell.appendChild(input); + tr.appendChild(cell); + + input.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + callbacks.editQuantity(costItemId, quantity.id, { + [key]: input.value, + }); + } + }); + } + + // if key contains value + else if (key.toLowerCase().includes("value")) { + const cell = document.createElement("td"); + const input = document.createElement("input"); + input.type = "number"; + input.value = value; + cell.appendChild(input); + + input.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + callbacks.editQuantity(costItemId, quantity.id, { + [key]: parseFloat(input.value), // Dynamically set the key and parse the value as a double + }); + } + }); + tr.appendChild(cell); + } else { + const cell = document.createElement("td"); + cell.textContent = value; + tr.appendChild(cell); + } + } + }); + + // Add delete button + const deleteButton = CostUI.createButton("Delete", "fa-solid fa-trash"); + const deleteCell = document.createElement("td"); + deleteButton.addEventListener("click", function (e) { + e.preventDefault(); + callbacks.deleteQuantity(costItemId, quantity.id); + tr.remove(); + }); + deleteCell.appendChild(deleteButton); + tr.appendChild(deleteCell); + + return tr; + } + + static createAddManualQuantityButton(costItemId, quantityType, callbacks) { + const addButton = CostUI.createButton( + "Add Manual Quantity", + "fa-solid fa-plus" + ); + + addButton.addEventListener("click", function (e) { + e.preventDefault(); + let type = quantityType; + if (!type) { + // get quantityType from dropdown + const qtoSection = document.getElementById( + "manual-quantities-section-" + costItemId + ); + const dropdown = qtoSection.querySelector("select"); + type = dropdown ? dropdown.value : null; + } + callbacks.addQuantity(costItemId, type); + }); + + return addButton; + } } diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index 187d1cb648..d25343fad8 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -76,7 +76,7 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - for obj in bpy.context.selected_objects: + for obj in tool.Blender.get_selected_objects(): element = tool.Ifc.get_entity(obj) if not element: continue diff --git a/src/bonsai/bonsai/bim/module/debug/__init__.py b/src/bonsai/bonsai/bim/module/debug/__init__.py index 629bb05dc6..1e19ff678a 100644 --- a/src/bonsai/bonsai/bim/module/debug/__init__.py +++ b/src/bonsai/bonsai/bim/module/debug/__init__.py @@ -36,6 +36,7 @@ classes = ( operator.ProfileImportIFC, operator.PurgeHdf5Cache, operator.PurgeUnusedElementsByClass, + operator.PurgeUnusedObjects, operator.RestartBlender, operator.RewindInspector, operator.SelectExpressFile, diff --git a/src/bonsai/bonsai/bim/module/debug/operator.py b/src/bonsai/bonsai/bim/module/debug/operator.py index 44c4eef5da..83ee47d07f 100644 --- a/src/bonsai/bonsai/bim/module/debug/operator.py +++ b/src/bonsai/bonsai/bim/module/debug/operator.py @@ -32,6 +32,8 @@ import ifcopenshell.util.representation import ifcopenshell.util.unit import bonsai.tool as tool import bonsai.core.debug as core +import bonsai.core.profile +import bonsai.core.type import bonsai.bim.handler import bonsai.bim.import_ifc as import_ifc from pathlib import Path @@ -608,6 +610,57 @@ class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator): tool.Ifc.get().write(self.filepath) +class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.purge_unused_objects" + bl_label = "Purge Unused Objects" + bl_options = {"REGISTER", "UNDO"} + + object_type: bpy.props.EnumProperty( + name="Object Type", + items=( + ("TYPE", "Type", ""), + ("PROFILE", "Profile", ""), + ("STYLE", "Style", ""), + ("MATERIAL", "Material", ""), + ), + ) + + def _execute(self, context): + object_type = self.object_type + if object_type == "TYPE": + purged = bonsai.core.type.purge_unused_types(tool.Ifc, tool.Type, tool.Geometry) + elif object_type == "PROFILE": + purged = bonsai.core.profile.purge_unused_profiles(tool.Ifc, tool.Profile) + elif object_type == "STYLE": + purged = tool.Debug.purge_unused_class("IfcPresentationStyle") + elif object_type == "MATERIAL": + ifc_file = tool.Ifc.get() + is_ifc2x3 = ifc_file.schema == "IFC2X3" + if is_ifc2x3: + purged = tool.Debug.purge_unused_class("IfcMaterial") + else: + purged = tool.Debug.purge_unused_class("IfcMaterialDefinition") + else: + self.report({"ERROR"}, f"Invalid object type {object_type}.") + return {"CANCELLED"} + + self.report({"INFO"}, f"{purged} unused {object_type.lower()}s were purged.") + + if purged == 0: + return + + scene = context.scene + if object_type == "PROFILE": + if scene.BIMProfileProperties.is_editing: + bpy.ops.bim.load_profiles() + elif object_type == "STYLE": + if scene.BIMStylesProperties.is_editing: + bpy.ops.bim.load_styles() + elif object_type == "MATERIAL": + if scene.BIMMaterialProperties.is_editing: + bpy.ops.bim.load_materials() + + class PipInstall(bpy.types.Operator): bl_idname = "bim.pip_install" bl_label = "Pip Install" diff --git a/src/bonsai/bonsai/bim/module/drawing/scheduler.py b/src/bonsai/bonsai/bim/module/drawing/scheduler.py index abe44ffd7a..568c3ca294 100644 --- a/src/bonsai/bonsai/bim/module/drawing/scheduler.py +++ b/src/bonsai/bonsai/bim/module/drawing/scheduler.py @@ -555,6 +555,8 @@ class Scheduler: if text_color: text_params["fill"] = text_color + text_params["class_"] = "schedule" + if len(text_lines) == 1 and not wrap_text: text_params.update(box_alignment_params) text_tag = self.svg.text(text_lines[0], insert=(x, y), **(text_params)) diff --git a/src/bonsai/bonsai/bim/module/geometry/__init__.py b/src/bonsai/bonsai/bim/module/geometry/__init__.py index f07aa0b19f..1caba354ba 100644 --- a/src/bonsai/bonsai/bim/module/geometry/__init__.py +++ b/src/bonsai/bonsai/bim/module/geometry/__init__.py @@ -36,6 +36,7 @@ classes = ( operator.EnableEditingRepresentationItems, operator.FlipObject, operator.GetRepresentationIfcParameters, + operator.ImportRepresentationItems, operator.OverrideDelete, operator.OverrideDuplicateMove, operator.OverrideDuplicateMoveLinked, @@ -60,6 +61,7 @@ classes = ( operator.UpdateParametricRepresentation, operator.UpdateRepresentation, prop.RepresentationItem, + prop.RepresentationItemObject, prop.ShapeAspect, prop.BIMObjectGeometryProperties, prop.BIMGeometryProperties, diff --git a/src/bonsai/bonsai/bim/module/geometry/data.py b/src/bonsai/bonsai/bim/module/geometry/data.py index 5f84838e14..0c8ba8eec1 100644 --- a/src/bonsai/bonsai/bim/module/geometry/data.py +++ b/src/bonsai/bonsai/bim/module/geometry/data.py @@ -52,7 +52,11 @@ class ViewportData: ("OBJECT", "IFC Object Mode", "", "OBJECT_DATAMODE", 0), ("EDIT", "IFC Edit Mode", "", "EDITMODE_HLT", 1), ] - if not obj or not tool.Blender.is_editable(obj): + if ( + not obj + or not tool.Blender.is_editable(obj) + or ((element := tool.Ifc.get_entity(obj)) and tool.Geometry.is_locked(element)) + ): return obj_mode return mesh_modes diff --git a/src/bonsai/bonsai/bim/module/geometry/decorator.py b/src/bonsai/bonsai/bim/module/geometry/decorator.py new file mode 100644 index 0000000000..4db123c04e --- /dev/null +++ b/src/bonsai/bonsai/bim/module/geometry/decorator.py @@ -0,0 +1,138 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2024 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import blf +import gpu +import json +import bmesh +import bonsai.tool as tool +from bpy.types import SpaceView3D +from mathutils import Vector +from gpu_extras.batch import batch_for_shader +from bpy_extras.view3d_utils import location_3d_to_region_2d + + +class ItemDecorator: + is_installed = False + handlers = [] + + @classmethod + def install(cls, context): + if cls.is_installed: + cls.uninstall() + handler = cls() + cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL")) + cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw, (context,), "WINDOW", "POST_VIEW")) + cls.is_installed = True + + @classmethod + def uninstall(cls): + for handler in cls.handlers: + try: + SpaceView3D.draw_handler_remove(handler, "WINDOW") + except ValueError: + pass + cls.is_installed = False + + def draw_batch(self, shader_type, content_pos, color, indices=None): + shader = self.line_shader if shader_type == "LINES" else self.shader + batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) + shader.uniform_float("color", color) + batch.draw(shader) + + def draw_text(self, context): + self.addon_prefs = tool.Blender.get_addon_preferences() + selected_elements_color = self.addon_prefs.decorator_color_selected + unselected_elements_color = self.addon_prefs.decorator_color_unselected + special_elements_color = self.addon_prefs.decorator_color_special + + font_id = 0 + blf.size(font_id, 12) + blf.enable(font_id, blf.SHADOW) + color = selected_elements_color + blf.color(font_id, *color) + + for item in context.scene.BIMGeometryProperties.item_objs: + if (obj := item.obj) and obj.hide_get() == False: + if obj.select_get(): + centroid = obj.matrix_world @ Vector(obj.bound_box[0]).lerp(Vector(obj.bound_box[6]), 0.5) + tag = obj.name.split("/")[1] + coords_2d = location_3d_to_region_2d(context.region, context.region_data, centroid) + if coords_2d: + w, h = blf.dimensions(font_id, tag) + coords_2d -= Vector((w * 0.5, h * 0.5)) + blf.position(font_id, coords_2d[0], coords_2d[1], 0) + blf.draw(font_id, tag) + + def draw(self, context): + def transparent_color(color, alpha=0.3): + color = [i for i in color] + color[3] = alpha + return color + + self.addon_prefs = tool.Blender.get_addon_preferences() + selected_elements_color = self.addon_prefs.decorator_color_selected + unselected_elements_color = self.addon_prefs.decorator_color_unselected + special_elements_color = self.addon_prefs.decorator_color_special + + gpu.state.point_size_set(6) + gpu.state.blend_set("ALPHA") + + self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + self.line_shader.bind() # required to be able to change uniforms of the shader + # POLYLINE_UNIFORM_COLOR specific uniforms + self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height)) + self.line_shader.uniform_float("lineWidth", 2.0) + + # general shader + self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") + + selected_verts = [] + selected_edges = [] + selected_tris = [] + unselected_verts = [] + unselected_edges = [] + unselected_tris = [] + for item in context.scene.BIMGeometryProperties.item_objs: + if (obj := item.obj) and obj.hide_get() == False: + if obj.select_get(): + if context.mode != "OBJECT": + continue + edges = selected_edges + verts = selected_verts + offset = len(selected_verts) + selected_verts.extend([tuple(obj.matrix_world @ v.co) for v in obj.data.vertices]) + selected_tris.extend([tuple([i + offset for i in t.vertices]) for t in obj.data.loop_triangles]) + else: + offset = len(unselected_verts) + unselected_verts.extend([tuple(obj.matrix_world @ v.co) for v in obj.data.vertices]) + unselected_tris.extend([tuple([i + offset for i in t.vertices]) for t in obj.data.loop_triangles]) + edges = unselected_edges + verts = unselected_verts + i = len(verts) + edges.extend([[ei + i for ei in e] for e in json.loads(item.edges)]) + matrix_world = obj.matrix_world + verts.extend([matrix_world @ Vector(v) for v in json.loads(item.verts)]) + + if unselected_verts: + self.draw_batch("LINES", unselected_verts, transparent_color(unselected_elements_color), unselected_edges) + self.draw_batch("TRIS", unselected_verts, transparent_color(special_elements_color), unselected_tris) + + if selected_verts: + self.draw_batch("LINES", selected_verts, selected_elements_color, selected_edges) + self.draw_batch("TRIS", selected_verts, transparent_color(selected_elements_color), selected_tris) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 4991f18978..6a7100347f 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -98,10 +98,19 @@ class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator): class OverrideOriginSet(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.override_origin_set" + blender_op = bpy.ops.object.origin_set.get_rna_type() bl_label = "IFC Origin Set" + bl_description = ( + blender_op.description + ".\nAlso makes sure changes are in sync with IFC (opeartor works only on IFC objects)" + ) bl_options = {"REGISTER", "UNDO"} obj: bpy.props.StringProperty() - origin_type: bpy.props.StringProperty() + blender_type_prop = blender_op.properties["type"] + origin_type: bpy.props.EnumProperty( + name=blender_type_prop.name, + default=blender_type_prop.default, + items=[(i.identifier, i.name, i.description) for i in blender_type_prop.enum_items], + ) def _execute(self, context): objs = [bpy.data.objects.get(self.obj)] if self.obj else context.selected_objects @@ -635,6 +644,7 @@ class OverrideDelete(bpy.types.Operator): element = tool.Ifc.get_entity(obj) if element: if tool.Geometry.is_locked(element): + self.report({"ERROR"}, f"Element '{obj.name}' is locked and cannot be deleted.") continue if ifcopenshell.util.element.get_pset(element, "BBIM_Array"): self.report({"INFO"}, "Elements that are part of an array cannot be deleted.") @@ -766,6 +776,7 @@ class OverrideOutlinerDelete(bpy.types.Operator): for obj in objects_to_delete: if element := tool.Ifc.get_entity(obj): if tool.Geometry.is_locked(element): + self.report({"ERROR"}, f"Element '{obj.name}' is locked and cannot be deleted.") if collection := obj.BIMObjectProperties.collection: collections_to_delete.discard(collection) continue @@ -882,6 +893,7 @@ class OverrideDuplicateMove(bpy.types.Operator): continue # For now, don't copy drawings until we stabilise a bit more. It's tricky. elif tool.Geometry.is_locked(element): obj.select_set(False) + self.report({"ERROR"}, f"Element '{obj.name}' is locked and cannot be duplicated.") continue linked_non_ifc_object = linked and not element @@ -1600,6 +1612,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): if not element: continue if tool.Geometry.is_locked(element): + self.report({"ERROR"}, f"Element '{obj.name}' is locked and cannot be edited.") obj.select_set(False) continue representation = tool.Geometry.get_active_representation(obj) @@ -2214,3 +2227,96 @@ class RemoveRepresentationItemFromShapeAspect(bpy.types.Operator, tool.Ifc.Opera if not styled_item.Styles: ifc_file.remove(styled_item) + + +class ImportRepresentationItems(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.xxx_import_representation_items" + bl_label = "Import Representation Items" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + obj = context.active_object + tool.Geometry.apply_item_ids_as_vertex_groups(obj) + tool.Geometry.dissolve_triangulated_edges(obj) + bm_dict = self.separate_faces_by_vertex_group(obj) + # bm_dict = self.separate_faces_by_id(obj) + props = context.scene.BIMGeometryProperties + props.item_objs.clear() + + for item_id, bm in bm_dict.items(): + item_mesh = bpy.data.meshes.new(f"mesh_id_{item_id}") + bm.to_mesh(item_mesh) + bm.free() + + item = tool.Ifc.get().by_id(item_id) + item_obj = bpy.data.objects.new(f"Item/{item.is_a()}/{item_id}", item_mesh) + item_obj.matrix_world = obj.matrix_world + item_obj.show_in_front = True + bpy.context.collection.objects.link(item_obj) + + new = props.item_objs.add() + new.obj = item_obj + + verts = [list(co) for co in item_obj.bound_box] + edges = [(0, 3), (3, 7), (7, 4), (4, 0), (0, 1), (3, 2), (7, 6), (4, 5), (1, 2), (2, 6), (6, 5), (5, 1)] + new.verts = json.dumps(verts) + new.edges = json.dumps(edges) + + def separate_faces_by_id(self, obj): + mesh = obj.data + bm = bmesh.new() + bm.from_mesh(mesh) + results = {} + face_ids = mesh["ios_item_ids"] + unique_ids = set(face_ids) + + for item_id in unique_ids: + item_bm = bmesh.new() + for face, face_id in zip(bm.faces, face_ids): + if face_id == item_id: + # Copy the face and its vertices into the new BMesh + new_face_verts = [item_bm.verts.new(v.co) for v in face.verts] + item_bm.faces.new(new_face_verts) + item_bm.verts.ensure_lookup_table() + item_bm.faces.ensure_lookup_table() + results[item_id] = item_bm + + return results + + def separate_faces_by_vertex_group(self, obj): + mesh = obj.data + bm = bmesh.new() + bm.from_mesh(mesh) + + # Ensure the bmesh has up-to-date vertex weights (vertex groups) + bm.verts.layers.deform.verify() + + results = {} + for vgroup in obj.vertex_groups: + group_bm = bmesh.new() + deform_layer = bm.verts.layers.deform.active + + # Iterate over the faces and check if all vertices of the face belong to the vertex group + for face in bm.faces: + face_in_group = True + for vert in face.verts: + # Get the vertex groups the vertex belongs to + deform = vert[deform_layer] + + # If this vertex does not belong to the current vertex group, mark the face as outside the group + if vgroup.index not in deform: + face_in_group = False + break + + # If all vertices of the face belong to the current vertex group, add the face to the new BMesh + if face_in_group: + new_face_verts = [group_bm.verts.new(v.co) for v in face.verts] + group_bm.faces.new(new_face_verts) + + # Ensure the mesh is valid and doesn't have duplicate elements + group_bm.verts.ensure_lookup_table() + group_bm.faces.ensure_lookup_table() + + results[int(vgroup.name.split("_")[3])] = group_bm + + return results diff --git a/src/bonsai/bonsai/bim/module/geometry/prop.py b/src/bonsai/bonsai/bim/module/geometry/prop.py index 641695a16e..1bb06837d8 100644 --- a/src/bonsai/bonsai/bim/module/geometry/prop.py +++ b/src/bonsai/bonsai/bim/module/geometry/prop.py @@ -18,7 +18,7 @@ import bpy import bonsai.tool as tool -from bonsai.bim.prop import StrProperty, Attribute +from bonsai.bim.prop import StrProperty, Attribute, ObjProperty from bonsai.bim.module.geometry.data import RepresentationsData, ViewportData from bpy.types import PropertyGroup from bpy.props import ( @@ -100,6 +100,16 @@ class RepresentationItem(PropertyGroup): tags: StringProperty(name="Tags") +class RepresentationItemObject(PropertyGroup): + name: StringProperty(name="Name") + ifc_definition_id: IntProperty(name="IFC Definition ID") + obj: PointerProperty(type=bpy.types.Object) + verts: StringProperty(name="Verts") + edges: StringProperty(name="Edges") + special_verts: StringProperty(name="Special Verts") + special_edges: StringProperty(name="Special Edges") + + class ShapeAspect(PropertyGroup): name: StringProperty( name="Name", @@ -137,6 +147,7 @@ class BIMGeometryProperties(PropertyGroup): should_force_triangulation: BoolProperty(name="Force Triangulation", default=False) is_changing_mode: BoolProperty(name="Is Changing Mode", default=False) mode: EnumProperty(items=get_mode, name="IFC Interaction Mode", update=update_mode) + item_objs: CollectionProperty(name="Item Objects", type=RepresentationItemObject) def is_object_valid_for_representation_copy(self, obj: bpy.types.Object) -> bool: return bool(obj != bpy.context.active_object and obj.data) diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index 2575e62c6c..1e6094e5fb 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -32,43 +32,47 @@ from bonsai.bim.module.material.prop import purge as material_prop_purge from bonsai.bim.ifc import IfcStore -class LoadMaterials(bpy.types.Operator, tool.Ifc.Operator): +class LoadMaterials(bpy.types.Operator): bl_idname = "bim.load_materials" bl_label = "Load Materials" bl_description = "Display list of named materials" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def execute(self, context): core.load_materials(tool.Material, context.scene.BIMMaterialProperties.material_type) + return {"FINISHED"} -class DisableEditingMaterials(bpy.types.Operator, tool.Ifc.Operator): +class DisableEditingMaterials(bpy.types.Operator): bl_idname = "bim.disable_editing_materials" bl_label = "Disable Editing Materials" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def execute(self, context): core.disable_editing_materials(tool.Material) + return {"FINISHED"} -class SelectByMaterial(bpy.types.Operator, tool.Ifc.Operator): +class SelectByMaterial(bpy.types.Operator): bl_idname = "bim.select_by_material" bl_label = "Select By Material" bl_options = {"REGISTER", "UNDO"} material: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): core.select_by_material(tool.Material, tool.Spatial, material=tool.Ifc.get().by_id(self.material)) + return {"FINISHED"} -class EnableEditingMaterial(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingMaterial(bpy.types.Operator): bl_idname = "bim.enable_editing_material" bl_label = "Enable Editing Material" bl_options = {"REGISTER", "UNDO"} material: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): core.enable_editing_material(tool.Material, material=tool.Ifc.get().by_id(self.material)) + return {"FINISHED"} class EditMaterial(bpy.types.Operator, tool.Ifc.Operator): @@ -81,14 +85,15 @@ class EditMaterial(bpy.types.Operator, tool.Ifc.Operator): core.edit_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material)) -class DisableEditingMaterial(bpy.types.Operator, tool.Ifc.Operator): +class DisableEditingMaterial(bpy.types.Operator): bl_idname = "bim.disable_editing_material" bl_label = "Disable Editing Material" bl_options = {"REGISTER", "UNDO"} material: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): core.disable_editing_material(tool.Material) + return {"FINISHED"} class AssignParameterizedProfile(bpy.types.Operator, tool.Ifc.Operator): @@ -776,13 +781,13 @@ class ContractMaterialCategory(bpy.types.Operator): return {"FINISHED"} -class EnableEditingMaterialStyle(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingMaterialStyle(bpy.types.Operator): bl_idname = "bim.enable_editing_material_style" bl_label = "Enable Editing Material Style" bl_options = {"REGISTER", "UNDO"} material: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): props = bpy.context.scene.BIMMaterialProperties props.active_material_id = self.material props.editing_material_type = "STYLE" @@ -800,6 +805,7 @@ class EnableEditingMaterialStyle(bpy.types.Operator, tool.Ifc.Operator): style = rep.Items[0].Styles[0] if style.Name: # props.styles only has named styles props.styles = str(rep.Items[0].Styles[0].id()) + return {"FINISHED"} class EditMaterialStyle(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index f3160453a8..e96bd74450 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -301,9 +301,10 @@ class ProfileDecorator: class PolylineDecorator: is_installed = False handlers = [] - mouse_pos = None - input_panel = None + event = None + input_type = None + input_ui = None angle_snap_mat = None angle_snap_loc = None use_default_container = False @@ -315,11 +316,8 @@ class PolylineDecorator: if cls.is_installed: cls.uninstall() handler = cls() - cls.handlers.append( - SpaceView3D.draw_handler_add(handler.draw_on_screen_menu, (context,), "WINDOW", "POST_PIXEL") - ) cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_measurements, (context,), "WINDOW", "POST_PIXEL")) - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_input_panel, (context,), "WINDOW", "POST_PIXEL")) + cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_input_ui, (context,), "WINDOW", "POST_PIXEL")) cls.handlers.append(SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW")) cls.is_installed = True @@ -333,203 +331,27 @@ class PolylineDecorator: cls.is_installed = False @classmethod - def set_mouse_position(cls, event): - cls.mouse_pos = event.mouse_region_x, event.mouse_region_y + def update(cls, event, tool_state, input_ui, snapping_point): + cls.event = event + cls.tool_state = tool_state + cls.input_ui = input_ui @classmethod - def set_input_panel(cls, input_panel, input_type): - cls.input_panel = input_panel - cls.input_type = input_type + def set_input_ui(cls, input_ui): + cls.input_ui = input_ui @classmethod def set_angle_axis_line(cls, start, end): cls.axis_start = start cls.axis_end = end - @classmethod - def set_axis_rectangle(cls, corners): - cls.axis_rectangle = [*corners] + # @classmethod + # def set_axis_rectangle(cls, corners): + # cls.axis_rectangle = [*corners] @classmethod - def set_use_default_container(cls, value=False): - cls.use_default_container = value - - @classmethod - def set_plane(cls, plane_origin, plane_normal): - cls.plane_origin = plane_origin - cls.plane_normal = plane_normal - - @classmethod - def set_instructions(cls, instructions): - cls.instructions = instructions - - @classmethod - def set_snap_info(cls, snap_info): - cls.snap_info = snap_info - - @classmethod - def calculate_distance_and_angle(cls, context, is_input_on): - - try: - polyline_data = context.scene.BIMModelProperties.polyline_point - default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z - last_point_data = polyline_data[len(polyline_data) - 1] - except: - default_container_elevation = 0 - last_point_data = None - - snap_prop = context.scene.BIMModelProperties.snap_mouse_point[0] - - if last_point_data: - last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) - else: - last_point = Vector((0, 0, 0)) - - if is_input_on: - if cls.use_default_container: - snap_vector = Vector( - (float(cls.input_panel["X"]), float(cls.input_panel["Y"]), default_container_elevation) - ) - else: - snap_vector = Vector( - (float(cls.input_panel["X"]), float(cls.input_panel["Y"]), float(cls.input_panel["Z"])) - ) - else: - if cls.use_default_container: - snap_vector = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) - else: - snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) - - second_to_last_point = None - if len(polyline_data) > 1: - second_to_last_point_data = polyline_data[len(polyline_data) - 2] - second_to_last_point = Vector( - (second_to_last_point_data.x, second_to_last_point_data.y, second_to_last_point_data.z) - ) - else: - # Creates a fake "second to last" point away from the first point but in the same x axis - # this allows to calculate the angle relative to x axis when there is only one point - second_to_last_point = Vector((last_point.x + 1000, last_point.y, last_point.z)) - - distance = (snap_vector - last_point).length - if distance > 0: - angle = tool.Cad.angle_3_vectors( - second_to_last_point, last_point, snap_vector, new_angle=None, degrees=True - ) - - # Round angle to the nearest 0.05 - angle = round(angle / 0.05) * 0.05 - - if cls.input_panel: - cls.input_panel["X"] = str(round(snap_vector.x, 3)) - cls.input_panel["Y"] = str(round(snap_vector.y, 3)) - if "Z" in list(cls.input_panel.keys()): - cls.input_panel["Z"] = str(round(snap_vector.z, 3)) - cls.input_panel["D"] = str(round(distance, 3)) - cls.input_panel["A"] = str(round(angle, 3)) - - return cls.input_panel - - return cls.input_panel - - @classmethod - def calculate_area(cls, context): - try: - polyline_data = context.scene.BIMModelProperties.polyline_point - except: - return cls.input_panel - - if len(polyline_data) < 3: - return cls.input_panel - - points = [] - for data in polyline_data: - points.append(Vector((data.x, data.y, data.z))) - - if points[0] == points[-1]: - points = points[1:] - - # TODO move this to CAD - # Calculate the normal vector of the plane formed by the first three vertices - v1, v2, v3 = points[:3] - normal = (v2 - v1).cross(v3 - v1).normalized() - - # Check if all points are coplanar - is_coplanar = True - tolerance = 1e-6 # Adjust this value as needed - for v in points: - if abs((v - v1).dot(normal)) > tolerance: - is_coplanar = False - - if is_coplanar: - area = 0 - for i in range(len(points)): - j = (i + 1) % len(points) - area += points[i].cross(points[j]).dot(normal) - - area = abs(area) / 2 - else: - area = 0 - - if "AREA" in list(cls.input_panel.keys()): - cls.input_panel["AREA"] = str(round(area, 4)) - return cls.input_panel - - @classmethod - def calculate_x_y_and_z(cls, context): - try: - polyline_data = context.scene.BIMModelProperties.polyline_point - default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z - last_point_data = polyline_data[len(polyline_data) - 1] - last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) - except: - default_container_elevation = 0 - last_point = Vector((0, 0, 0)) - - snap_prop = context.scene.BIMModelProperties.snap_mouse_point[0] - snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) - - if cls.use_default_container: - snap_vector = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) - else: - snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) - - if len(polyline_data) > 1: - second_to_last_point_data = polyline_data[len(polyline_data) - 2] - second_to_last_point = Vector( - (second_to_last_point_data.x, second_to_last_point_data.y, second_to_last_point_data.z) - ) - else: - # Creates a fake "second to last" point away from the first point but in the same x axis - # this allows to calculate the angle relative to x axis when there is only one point - second_to_last_point = Vector((last_point.x + 1000, last_point.y, last_point.z)) - - distance = float(cls.input_panel["D"]) - - if distance < 0 or distance > 0: - angle = radians(float(cls.input_panel["A"])) - - rot_vector = tool.Cad.angle_3_vectors(second_to_last_point, last_point, snap_vector, angle, degrees=True) - - coords = rot_vector * distance + last_point - - x = coords[0] - y = coords[1] - z = coords[2] - if cls.input_panel: - cls.input_panel["X"] = str(round(x, 3)) - cls.input_panel["Y"] = str(round(y, 3)) - if "Z" in list(cls.input_panel.keys()): - cls.input_panel["Z"] = str(round(z, 3)) - - return cls.input_panel - - cls.input_panel["X"] = str(round(last_point.x, 3)) - cls.input_panel["Y"] = str(round(last_point.y, 3)) - if "Z" in list(cls.input_panel.keys()): - cls.input_panel["Z"] = str(round(last_point.z, 3)) - - return cls.input_panel + def set_tool_state(cls, tool_state): + cls.tool_state = tool_state def draw_batch(self, shader_type, content_pos, color, indices=None): shader = self.line_shader if shader_type == "LINES" else self.shader @@ -537,21 +359,7 @@ class PolylineDecorator: shader.uniform_float("color", color) batch.draw(shader) - @classmethod - def format_input_panel_units(cls, context, value): - unit_system = tool.Drawing.get_unit_system() - if unit_system == "IMPERIAL": - precision = context.scene.DocProperties.imperial_precision - factor = 3.28084 - else: - precision = None - factor = 1 - if context.scene.unit_settings.length_unit == "MILLIMETERS": - factor = 1000 - - return format_distance(value * factor, precision=precision, suppress_zero_inches=True, in_unit_length=True) - - def draw_input_panel(self, context): + def draw_input_ui(self, context): texts = { "D": "Distance: ", "A": "Angle: ", @@ -560,6 +368,7 @@ class PolylineDecorator: "Z": "Z coord:", "AREA": "Area: ", } + mouse_pos = self.event.mouse_region_x, self.event.mouse_region_y self.addon_prefs = tool.Blender.get_addon_preferences() self.font_id = 0 @@ -571,22 +380,21 @@ class PolylineDecorator: color_highlight = self.addon_prefs.decorator_color_special offset = 20 new_line = 20 - for i, (key, value) in enumerate(self.input_panel.items()): + for i, (key, field_name) in enumerate(texts.items()): - if key != "A" and key != self.input_type: - value = float(value) - formatted_value = self.format_input_panel_units(context, value) + if key != self.tool_state.input_type: + formatted_value = self.input_ui.get_formatted_value(key) else: - formatted_value = value + formatted_value = self.input_ui.get_text_value(key) - if key not in list(texts.keys()): + if formatted_value is None: continue - if key == self.input_type: + if key == self.tool_state.input_type: blf.color(self.font_id, *color_highlight) else: blf.color(self.font_id, *color) - blf.position(self.font_id, self.mouse_pos[0] + offset, self.mouse_pos[1] - (new_line * i), 0) - blf.draw(self.font_id, texts[key] + formatted_value) + blf.position(self.font_id, mouse_pos[0] + offset, mouse_pos[1] - (new_line * i), 0) + blf.draw(self.font_id, field_name + formatted_value) def draw_measurements(self, context): region = context.region @@ -608,9 +416,7 @@ class PolylineDecorator: pos_dim = (Vector(measurement_prop[i].position) + Vector(measurement_prop[i - 1].position)) / 2 coords_dim = view3d_utils.location_3d_to_region_2d(region, rv3d, pos_dim) - value = measurement_prop[i].dim - value = float(value) - formatted_value = self.format_input_panel_units(context, value) + formatted_value = measurement_prop[i].dim blf.position(self.font_id, coords_dim[0], coords_dim[1], 0) blf.draw(self.font_id, "d: " + formatted_value) @@ -622,28 +428,6 @@ class PolylineDecorator: blf.position(self.font_id, coords_angle[0], coords_angle[1], 0) blf.draw(self.font_id, "a: " + measurement_prop[i].angle) - def draw_on_screen_menu(self, context): - region = context.region - - self.addon_prefs = tool.Blender.get_addon_preferences() - self.font_id = 2 - font_size = tool.Blender.scale_font_size(12) - blf.size(self.font_id, font_size) - blf.enable(self.font_id, blf.SHADOW) - blf.shadow(self.font_id, 6, 0, 0, 0, 1) - color = self.addon_prefs.decorations_colour - blf.color(self.font_id, *color) - - text_w, text_h = blf.dimensions(0, self.instructions) - position = (region.width / 2) - (text_w / 2) - blf.position(self.font_id, position, 10, 0) - blf.draw(self.font_id, self.instructions) - - text_w, text_h = blf.dimensions(0, self.snap_info) - position = (region.width / 2) - (text_w / 2) - blf.position(self.font_id, position, 30, 0) - blf.draw(self.font_id, self.snap_info) - def __call__(self, context): self.addon_prefs = tool.Blender.get_addon_preferences() @@ -676,7 +460,7 @@ class PolylineDecorator: default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z projection_point = [] - if self.use_default_container: + if self.tool_state.use_default_container: # When a point is above the plane it projects the point # to the plane and creates a line if snap_prop.snap_type != "Plane" and snap_prop.z != 0: @@ -702,18 +486,18 @@ class PolylineDecorator: self.line_shader.uniform_float("lineWidth", 0.75) self.draw_batch("LINES", [self.axis_start, self.axis_end], decorator_color_unselected, [(0, 1)]) - try: - self.draw_batch("TRIS", self.axis_rectangle, (1, 1, 1, 0.1), [(0, 1, 3), (0, 2, 3)]) - except: - pass + # try: + # self.draw_batch("TRIS", self.axis_rectangle, (1, 1, 1, 0.1), [(0, 1, 3), (0, 2, 3)]) + # except: + # pass # Area highlight - if "AREA" in list(self.input_panel.keys()): - if self.input_panel["AREA"] and float(self.input_panel["AREA"]) > 0: - edges = [] - for i in range(1, len(polyline_points) - 1): - edges.append((0, i, i + 1)) - self.draw_batch("TRIS", polyline_points, (0, 1, 0, 0.1), edges) + # if "AREA" in list(self.input_panel.keys()): # TODO Change to input_ui + # if self.input_panel["AREA"] and float(self.input_panel["AREA"]) > 0: # TODO Change to input_ui + # edges = [] + # for i in range(1, len(polyline_points) - 1): + # edges.append((0, i, i + 1)) + # self.draw_batch("TRIS", polyline_points, (0, 1, 0, 0.1), edges) # Mouse points if snap_prop.snap_type in ["Face", "Plane"]: diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py new file mode 100644 index 0000000000..a828309696 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/model/polyline.py @@ -0,0 +1,353 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2024 Bruno Perdigão +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import bpy +import copy +import math +import bmesh +import ifcopenshell +import ifcopenshell.api +import ifcopenshell.util.unit +import ifcopenshell.util.element +import ifcopenshell.util.placement +import ifcopenshell.util.representation +import ifcopenshell.util.type +import mathutils.geometry +import bonsai.core.type +import bonsai.core.root +import bonsai.core.geometry +import bonsai.core.model as core +import bonsai.tool as tool +from bonsai.bim.ifc import IfcStore +from math import pi, sin, cos, degrees +from mathutils import Vector, Matrix +from bonsai.bim.module.model.opening import FilledOpeningGenerator +from bonsai.bim.module.model.decorator import PolylineDecorator +from typing import Optional +from lark import Lark, Transformer + + +class PolylineOperator: + # TODO Fill doc strings + """ """ + + @classmethod + def poll(cls, context): + return context.space_data.type == "VIEW_3D" + + def __init__(self): + self.mousemove_count = 0 + self.action_count = 0 + self.visible_objs = [] + self.objs_2d_bbox = [] + self.number_options = { + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + " ", + ".", + "+", + "-", + "*", + "/", + "'", + '"', + "=", + } + self.number_input = [] + self.number_output = "" + self.number_is_negative = False + self.input_options = ["D", "A", "X", "Y"] + self.input_type = None + self.input_type = None + self.input_value_xy = [None, None] + self.input_ui = tool.Polyline.create_input_ui() + self.is_typing = False + self.snap_angle = None + self.snapping_points = [] + self.instructions = """TAB: Cycle Input + D: Distance Input + A: Angle Input + M: Modify Snap Point + C: Close Polyline + BACKSPACE: Remove Point + X, Y: Choose Axis + SHIFT: Lock axis + """ + self.snap_info = """ + Snap: + Axis: + Plane: + """ + + self.tool_state = tool.Polyline.create_tool_state() + + def recalculate_inputs(self, context): + if self.number_input: + is_valid, self.number_output = tool.Polyline.validate_input(self.number_output, self.input_type) + self.input_ui.set_value(self.input_type, self.number_output) + if not is_valid: + self.report({"WARNING"}, "The number typed is not valid.") + return is_valid + else: + if self.input_type in {"X", "Y"}: + tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state) + elif self.input_type in {"D", "A"}: + tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state) + tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state) + else: + self.input_ui.set_value(self.input_type, self.number_output) + tool.Blender.update_viewport() + return is_valid + + def choose_axis(self, event, x=True, y=True, z=False): + if x: + if event.value == "PRESS" and event.type == "X": + self.tool_state.axis_method = "X" if self.tool_state.axis_method != event.type else None + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + if y: + if event.value == "PRESS" and event.type == "Y": + self.tool_state.axis_method = "Y" if self.tool_state.axis_method != event.type else None + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + if z: + if event.value == "PRESS" and event.type == "Z": + self.tool_state.axis_method = "Z" if self.tool_state.axis_method != event.type else None + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + def choose_plane(self, event, x=True, y=True, z=True): + if x: + if event.shift and event.value == "PRESS" and event.type == "X": + self.tool_state.use_default_container = False + self.tool_state.plane_method = "YZ" + self.tool_state.axis_method = None + tool.Blender.update_viewport() + + if y: + if event.shift and event.value == "PRESS" and event.type == "Y": + self.tool_state.use_default_container = False + self.tool_state.plane_method = "XZ" + self.tool_state.axis_method = None + tool.Blender.update_viewport() + + if z: + if event.shift and event.value == "PRESS" and event.type == "Z": + self.tool_state.use_default_container = False + self.tool_state.plane_method = "XY" + self.tool_state.axis_method = None + tool.Blender.update_viewport() + + def handle_instructions(self, context): + self.snap_info = f"""| + Axis: {self.tool_state.axis_method} + Plane: {self.tool_state.plane_method} + Snap: {self.snapping_points[0][1]} + """ + context.workspace.status_text_set(self.instructions + self.snap_info) + + def handle_keyboard_input(self, context, event): + + if self.tool_state.is_input_on and event.value == "PRESS" and event.type == "TAB": + self.recalculate_inputs(context) + index = self.input_options.index(self.input_type) + size = len(self.input_options) + self.input_type = self.input_options[((index + 1) % size)] + self.tool_state.input_type = self.input_options[((index + 1) % size)] + self.tool_state.mode = "Select" + self.is_typing = False + self.number_input = self.input_ui.get_formatted_value(self.input_type) + self.number_input = list(self.number_input) + self.number_output = "".join(self.number_input) + self.input_ui.set_value(self.input_type, self.number_output) + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + if not self.tool_state.is_input_on and event.value == "RELEASE" and event.type == "TAB": + self.recalculate_inputs(context) + self.tool_state.mode = "Select" + self.tool_state.is_input_on = True + self.input_type = "D" + self.tool_state.input_type = "D" + self.is_typing = False + self.number_input = self.input_ui.get_formatted_value(self.input_type) + self.number_input = list(self.number_input) + self.number_output = "".join(self.number_input) + self.input_ui.set_value(self.input_type, self.number_output) + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + if not self.tool_state.is_input_on and event.ascii in self.number_options: + self.recalculate_inputs(context) + self.tool_state.mode = "Edit" + self.tool_state.is_input_on = True + self.input_type = "D" + self.tool_state.input_type = "D" + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + if event.value == "RELEASE" and event.type in {"D", "A"}: + self.recalculate_inputs(context) + self.tool_state.mode = "Edit" + self.tool_state.is_input_on = True + self.input_type = event.type + self.tool_state.input_type = event.type + self.input_ui.set_value(self.input_type, "") + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + if self.input_type in self.input_options: + if (event.ascii in self.number_options) or (event.value == "RELEASE" and event.type == "BACK_SPACE"): + if not self.tool_state.mode == "Edit" and not (event.ascii == "=" or event.type == "BACK_SPACE"): + self.number_input = [] + + if event.type == "BACK_SPACE": + if len(self.number_input) <= 1: + self.number_input = [] + else: + self.number_input.pop(-1) + elif event.ascii == "=": + if self.number_input[0] == "=": + self.number_input.pop(0) + else: + self.number_input.insert(0, "=") + else: + self.number_input.append(event.ascii) + + if not self.number_input: + self.number_output = "0" + + self.tool_state.mode = "Edit" + self.is_typing = True + self.number_output = "".join(self.number_input) + self.input_ui.set_value(self.input_type, self.number_output) + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + def handle_inserting_polyline(self, context, event): + if event.value == "RELEASE" and event.type == "LEFTMOUSE": + tool.Snap.insert_polyline_point(self.input_ui) + tool.Blender.update_viewport() + + if event.value == "PRESS" and event.type == "C": + tool.Snap.close_polyline() + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + if ( + self.tool_state.is_input_on + and event.value == "RELEASE" + and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"} + ): + is_valid = self.recalculate_inputs(context) + if is_valid: + tool.Snap.insert_polyline_point(self.input_ui) + self.tool_state.mode = "Mouse" + self.tool_state.is_input_on = False + self.input_type = None + self.tool_state.input_type = None + self.number_input = [] + self.number_output = "" + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + def handle_snap_selection(self, context, event): + if event.value == "PRESS" and event.type == "M": + self.snapping_points = tool.Snap.modify_snapping_point_selection(self.snapping_points) + tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state) + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + def handle_cancelation(self, context, event): + if self.tool_state.is_input_on: + if event.value == "RELEASE" and event.type in {"ESC"}: + self.recalculate_inputs(context) + self.tool_state.mode = "Mouse" + self.tool_state.is_input_on = False + self.input_type = None + self.tool_state.input_type = None + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + else: + if event.value == "RELEASE" and event.type in {"ESC"}: + self.tool_state.axis_method = None + context.workspace.status_text_set(text=None) + PolylineDecorator.uninstall() + tool.Snap.clear_polyline() + tool.Blender.update_viewport() + return {"CANCELLED"} + + def handle_mouse_move(self, context, event): + if not self.tool_state.is_input_on: + if event.type == "MOUSEMOVE" or event.type == "INBETWEEN_MOUSEMOVE": + self.mousemove_count += 1 + self.tool_state.mode = "Mouse" + self.tool_state.is_input_on = False + self.input_type = None + self.tool_state.input_type = None + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Snap.clear_snapping_ref() + tool.Blender.update_viewport() + else: + self.mousemove_count = 0 + + if self.mousemove_count == 2: + self.objs_2d_bbox = [] + for obj in self.visible_objs: + self.objs_2d_bbox.append(tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj)) + + if self.mousemove_count > 3: + detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state) + self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps) + tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state) + tool.Blender.update_viewport() + return {"RUNNING_MODAL"} + + if event.value == "RELEASE" and event.type == "BACK_SPACE": + tool.Snap.remove_last_polyline_point() + tool.Blender.update_viewport() + + def invoke(self, context, event): + PolylineDecorator.install(context) + tool.Snap.clear_snapping_point() + + self.tool_state.use_default_container = False + self.tool_state.axis_method = None + self.tool_state.plane_method = None + self.tool_state.mode = "Mouse" + tool.Snap.set_tool_state(self.tool_state) + self.visible_objs = tool.Raycast.get_visible_objects(context) + for obj in self.visible_objs: + self.objs_2d_bbox.append(tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj)) + detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state) + self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps) + tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state) + + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + context.window_manager.modal_handler_add(self) + # return {"RUNNING_MODAL"} diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 21b57f8f4f..a5ddd83a7a 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -38,6 +38,7 @@ from math import pi, sin, cos, degrees from mathutils import Vector, Matrix from bonsai.bim.module.model.opening import FilledOpeningGenerator from bonsai.bim.module.model.decorator import PolylineDecorator +from bonsai.bim.module.model.polyline import PolylineOperator from typing import Optional from lark import Lark, Transformer @@ -284,7 +285,7 @@ def recalculate_dumb_wall_origin(wall, new_origin=None): child.matrix_parent_inverse = wall.matrix_world.inverted() -class DrawPolylineWall(bpy.types.Operator): +class DrawPolylineWall(bpy.types.Operator, PolylineOperator): bl_idname = "bim.draw_polyline_wall" bl_label = "Draw Polyline Wall" bl_options = {"REGISTER", "UNDO"} @@ -294,66 +295,7 @@ class DrawPolylineWall(bpy.types.Operator): return context.space_data.type == "VIEW_3D" def __init__(self): - self.mousemove_count = 0 - self.action_count = 0 - self.visible_objs = [] - self.objs_2d_bbox = [] - self.number_options = { - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - " ", - ".", - "+", - "-", - "*", - "/", - "'", - '"', - "=", - } - self.number_input = [] - self.number_output = "" - self.number_is_negative = False - self.is_input_on = False - self.input_options = ["D", "A", "X", "Y"] - self.input_type = "OFF" - self.input_value_xy = [None, None] - self.input_panel = {"D": "", "A": "", "X": "", "Y": ""} - self.snap_angle = None - self.snapping_points = [] - self.instructions = """TAB: Cycle Input - M: Modify Snap Point - C: Close - Backspace: Remove - X Y: Axis - Shift: Lock axis -""" - - def recalculate_inputs(self, context): - if self.number_input: - is_valid, self.number_output = tool.Snap.validate_input(self.number_output, self.input_type) - self.input_panel[self.input_type] = self.number_output - if not is_valid: - self.report({"WARNING"}, "The number typed is not valid.") - return is_valid - else: - if self.input_type in {"X", "Y"}: - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - elif self.input_type in {"D", "A"}: - self.input_panel = PolylineDecorator.calculate_x_y_and_z(context) - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - else: - self.input_panel[self.input_type] = self.number_output - tool.Blender.update_viewport() - return is_valid + super().__init__() # TODO This is creating a hack in generate function from DumbWallGenerator # Come up with a better solution @@ -382,187 +324,44 @@ class DrawPolylineWall(bpy.types.Operator): DumbWallJoiner().join_V(wall1["obj"], wall2["obj"]) def modal(self, context, event): + if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: + return {"PASS_THROUGH"} - if not self.is_input_on: - if event.type == "MOUSEMOVE" or event.type == "INBETWEEN_MOUSEMOVE": - self.mousemove_count += 1 - self.is_input_on = False - self.input_type = "OFF" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Snap.clear_snapping_ref() - tool.Blender.update_viewport() - else: - self.mousemove_count = 0 + self.handle_instructions(context) - if self.mousemove_count == 2: - self.objs_2d_bbox = [] - for obj in self.visible_objs: - self.objs_2d_bbox.append(tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj)) + self.handle_mouse_move(context, event) - if self.mousemove_count > 3: - detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox) - self.snapping_points = tool.Snap.select_snapping_points(context, event, detected_snaps) - PolylineDecorator.set_mouse_position(event) - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - tool.Blender.update_viewport() - return {"RUNNING_MODAL"} + self.choose_axis(event) - if event.value == "RELEASE" and event.type == "BACK_SPACE": - tool.Snap.remove_last_polyline_point() - tool.Blender.update_viewport() + self.handle_snap_selection(context, event) - if event.value == "RELEASE" and event.type == "LEFTMOUSE": - tool.Snap.insert_polyline_point(self.input_panel) - tool.Blender.update_viewport() - - if event.value == "PRESS" and event.type == "X": - tool.Snap.set_snap_axis_method("X") - tool.Blender.update_viewport() - - if event.value == "PRESS" and event.type == "Y": - tool.Snap.set_snap_axis_method("Y") - tool.Blender.update_viewport() - - if event.value == "PRESS" and event.type == "C": - tool.Snap.close_polyline() - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if self.is_input_on and event.value == "PRESS" and event.type == "TAB": - self.recalculate_inputs(context) - index = self.input_options.index(self.input_type) - size = len(self.input_options) - self.input_type = self.input_options[((index + 1) % size)] - - self.number_input = self.input_panel[self.input_type] - self.number_input = list(self.number_input) - self.number_output = "".join(self.number_input) - if self.input_type != "A": - self.number_output = PolylineDecorator.format_input_panel_units(context, float(self.number_output)) - - self.input_panel[self.input_type] = self.number_output - - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if not self.is_input_on and event.value == "RELEASE" and event.type == "TAB": - self.recalculate_inputs(context) - self.is_input_on = True - self.input_type = "D" - - self.number_input = self.input_panel[self.input_type] - self.number_input = list(self.number_input) - self.number_output = "".join(self.number_input) - self.number_output = PolylineDecorator.format_input_panel_units(context, float(self.number_output)) - self.input_panel[self.input_type] = self.number_output - - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if not self.is_input_on and event.ascii in self.number_options: - self.recalculate_inputs(context) - self.is_input_on = True - self.input_type = "D" - self.number_input = [] - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if event.value == "RELEASE" and event.type in {"D", "A"}: - self.recalculate_inputs(context) - self.is_input_on = True - self.input_type = event.type - self.number_input = [] - self.input_panel[self.input_type] = "" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if self.input_type in self.input_options: - if (event.ascii in self.number_options) or (event.value == "RELEASE" and event.type == "BACK_SPACE"): - if event.type == "BACK_SPACE": - if len(self.number_input) <= 1: - self.number_input = [] - else: - self.number_input = self.number_input[:-1] - self.number_output = "".join(self.number_input) - if not self.number_input: - self.number_output = "0" - self.input_panel[self.input_type] = self.number_output - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - else: - self.number_input.append(event.ascii) - self.number_output = "".join(self.number_input) - - if self.number_input: - self.input_panel[self.input_type] = self.number_output - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if not self.is_input_on and event.value == "RELEASE" and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}: + if ( + not self.tool_state.is_input_on + and event.value == "RELEASE" + and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"} + ): self.create_walls_from_polyline(context) + context.workspace.status_text_set(text=None) PolylineDecorator.uninstall() tool.Snap.clear_polyline() tool.Blender.update_viewport() return {"FINISHED"} - if self.is_input_on and event.value == "RELEASE" and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}: - is_valid = self.recalculate_inputs(context) - if is_valid: - tool.Snap.insert_polyline_point(self.input_panel) - self.is_input_on = False - self.input_type = "OFF" - self.number_input = [] - self.number_output = "" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() + self.handle_keyboard_input(context, event) - if event.value == "PRESS" and event.type == "M": - self.snapping_points = tool.Snap.modify_snapping_point_selection(self.snapping_points) - PolylineDecorator.set_mouse_position(event) - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - tool.Blender.update_viewport() + self.handle_inserting_polyline(context, event) - if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: - return {"PASS_THROUGH"} - - if self.is_input_on: - if event.value == "RELEASE" and event.type in {"ESC"}: - self.recalculate_inputs(context) - self.is_input_on = False - self.input_type = "OFF" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - else: - if event.value == "RELEASE" and event.type in {"ESC"}: - tool.Snap.set_snap_axis_method(None) - PolylineDecorator.uninstall() - tool.Snap.clear_polyline() - tool.Blender.update_viewport() - return {"CANCELLED"} + result = self.handle_cancelation(context, event) + if result is not None: + return result return {"RUNNING_MODAL"} def invoke(self, context, event): - if context.space_data.type == "VIEW_3D": - PolylineDecorator.install(context) - tool.Snap.set_use_default_container(True) - PolylineDecorator.set_use_default_container(True) - tool.Snap.set_snap_plane_method("XY") - PolylineDecorator.set_instructions(self.instructions) - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - self.visible_objs = tool.Raycast.get_visible_objects(context) - for obj in self.visible_objs: - self.objs_2d_bbox.append(tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj)) - detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox) - self.snapping_points = tool.Snap.select_snapping_points(context, event, detected_snaps) - PolylineDecorator.set_mouse_position(event) - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - tool.Blender.update_viewport() - context.window_manager.modal_handler_add(self) - return {"RUNNING_MODAL"} - else: - self.report({"WARNING"}, "Active space must be a View3d") - return {"CANCELLED"} + super().invoke(context, event) + self.tool_state.use_default_container = True + self.tool_state.plane_method = "XY" + return {"RUNNING_MODAL"} class DumbWallAligner: diff --git a/src/bonsai/bonsai/bim/module/profile/__init__.py b/src/bonsai/bonsai/bim/module/profile/__init__.py index d06bc1c78c..a9a4895884 100644 --- a/src/bonsai/bonsai/bim/module/profile/__init__.py +++ b/src/bonsai/bonsai/bim/module/profile/__init__.py @@ -30,7 +30,6 @@ classes = ( operator.EnableEditingArbitraryProfile, operator.EnableEditingProfile, operator.LoadProfiles, - operator.PurgeUnusedProfiles, operator.RemoveProfileDef, prop.Profile, prop.BIMProfileProperties, diff --git a/src/bonsai/bonsai/bim/module/profile/data.py b/src/bonsai/bonsai/bim/module/profile/data.py index 2402192aad..2ea97a9a20 100644 --- a/src/bonsai/bonsai/bim/module/profile/data.py +++ b/src/bonsai/bonsai/bim/module/profile/data.py @@ -29,6 +29,7 @@ def refresh(): class ProfileData: data = {} + failed_previews: set[int] = set() preview_collection = bpy.utils.previews.new() is_loaded = False diff --git a/src/bonsai/bonsai/bim/module/profile/operator.py b/src/bonsai/bonsai/bim/module/profile/operator.py index 2c0a7faee8..cb7cd27491 100644 --- a/src/bonsai/bonsai/bim/module/profile/operator.py +++ b/src/bonsai/bonsai/bim/module/profile/operator.py @@ -18,6 +18,7 @@ import bpy import ifcopenshell.api +import ifcopenshell.util.element import bonsai.bim.helper import bonsai.tool as tool import bonsai.bim.module.model.profile as model_profile @@ -97,17 +98,18 @@ class RemoveProfileDef(bpy.types.Operator, tool.Ifc.Operator): props.active_profile_index = min(current_index, len(props.profiles) - 1) -class EnableEditingProfile(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingProfile(bpy.types.Operator): bl_idname = "bim.enable_editing_profile" bl_label = "Enable Editing Profile" bl_options = {"REGISTER", "UNDO"} profile: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): props = context.scene.BIMProfileProperties props.profile_attributes.clear() bonsai.bim.helper.import_attributes2(tool.Ifc.get().by_id(self.profile), props.profile_attributes) props.active_profile_id = self.profile + return {"FINISHED"} class DisableEditingProfile(bpy.types.Operator): @@ -149,6 +151,7 @@ class AddProfileDef(bpy.types.Operator, tool.Ifc.Operator): profile = ifcopenshell.api.run("profile.add_arbitrary_profile", tool.Ifc.get(), profile=points) else: profile = ifcopenshell.api.run("profile.add_parameterized_profile", tool.Ifc.get(), ifc_class=profile_class) + tool.Profile.set_default_profile_attrs(profile) profile.ProfileName = "New Profile" bpy.ops.bim.load_profiles() @@ -174,12 +177,12 @@ class DuplicateProfileDef(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.load_profiles() -class EnableEditingArbitraryProfile(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingArbitraryProfile(bpy.types.Operator): bl_idname = "bim.enable_editing_arbitrary_profile" bl_label = "Enable Editing Arbitrary Profile" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def execute(self, context): props = context.scene.BIMProfileProperties active_profile = props.profiles[props.active_profile_index] profile_id = active_profile.ifc_definition_id @@ -192,6 +195,7 @@ class EnableEditingArbitraryProfile(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.object.mode_set(mode="EDIT") ProfileDecorator.install(context, exit_edit_mode_callback=lambda: disable_editing_arbitrary_profile(context)) tool.Blender.set_viewport_tool("bim.cad_tool") + return {"FINISHED"} def disable_editing_arbitrary_profile(context): @@ -210,13 +214,14 @@ def disable_editing_arbitrary_profile(context): refresh() -class DisableEditingArbitraryProfile(bpy.types.Operator, tool.Ifc.Operator): +class DisableEditingArbitraryProfile(bpy.types.Operator): bl_idname = "bim.disable_editing_arbitrary_profile" bl_label = "Disable Editing Arbitrary Profile" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): - return disable_editing_arbitrary_profile(context) + def execute(self, context): + disable_editing_arbitrary_profile(context) + return {"FINISHED"} class EditArbitraryProfile(bpy.types.Operator, tool.Ifc.Operator): @@ -262,18 +267,3 @@ class EditArbitraryProfile(bpy.types.Operator, tool.Ifc.Operator): props.active_arbitrary_profile_id = 0 model_profile.DumbProfileRegenerator().regenerate_from_profile_def(profile) - - -class PurgeUnusedProfiles(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.purge_unused_profiles" - bl_label = "Purge Unused Profiles" - bl_options = {"REGISTER", "UNDO"} - - def _execute(self, context): - props = context.scene.BIMProfileProperties - purged_profiles = core.purge_unused_profiles(tool.Ifc, tool.Profile) - self.report({"INFO"}, f"{purged_profiles} profiles were purged.") - - if props.is_editing: - refresh() - bpy.ops.bim.load_profiles() diff --git a/src/bonsai/bonsai/bim/module/profile/prop.py b/src/bonsai/bonsai/bim/module/profile/prop.py index afe21b1446..9fa4f5e360 100644 --- a/src/bonsai/bonsai/bim/module/profile/prop.py +++ b/src/bonsai/bonsai/bim/module/profile/prop.py @@ -81,14 +81,24 @@ def generate_thumbnail_for_active_profile(): if not props.profiles: bpy.ops.bim.load_profiles() - profile_id = props.profiles[props.active_profile_index].ifc_definition_id + active_profile = tool.Profile.get_active_profile_ui() + assert active_profile + profile_id = active_profile.ifc_definition_id profile = ifc_file.by_id(profile_id) # generate image size = 128 img = Image.new("RGBA", (size, size)) draw = ImageDraw.Draw(img) - tool.Profile.draw_image_for_ifc_profile(draw, profile, size) + + try: + tool.Profile.draw_image_for_ifc_profile(draw, profile, size) + except RuntimeError as e: + print(f"Failed to generate preview image for profile '{profile}': '{e}'.") + ProfileData.failed_previews.add(profile_id) + return + + ProfileData.failed_previews.discard(profile_id) pixels = [item for sublist in img.getdata() for item in sublist] # save generated image to preview collection diff --git a/src/bonsai/bonsai/bim/module/profile/ui.py b/src/bonsai/bonsai/bim/module/profile/ui.py index 56c5aefe96..23eecc0037 100644 --- a/src/bonsai/bonsai/bim/module/profile/ui.py +++ b/src/bonsai/bonsai/bim/module/profile/ui.py @@ -43,19 +43,22 @@ class BIM_PT_profiles(Panel): self.props = context.scene.BIMProfileProperties active_profile = None - if self.props.is_editing and self.props.profiles and self.props.active_profile_index < len(self.props.profiles): + if self.props.is_editing and (active_profile := tool.Profile.get_active_profile_ui()): preview_collection = ProfileData.preview_collection box = self.layout.box() - active_profile = self.props.profiles[self.props.active_profile_index] profile_id = active_profile.ifc_definition_id - profile_id_str = str(profile_id) - if profile_id_str in preview_collection: - preview_image = preview_collection[profile_id_str] - else: - preview_image = preview_collection.new(profile_id_str) - generate_thumbnail_for_active_profile() - box.template_icon(icon_value=preview_image.icon_id, scale=5) + if profile_id in ProfileData.failed_previews: + box.label(text="Failed to load preview (invalid profile).", icon="ERROR") + else: + profile_id_str = str(profile_id) + if profile_id_str in preview_collection: + preview_image = preview_collection[profile_id_str] + else: + preview_image = preview_collection.new(profile_id_str) + generate_thumbnail_for_active_profile() + + box.template_icon(icon_value=preview_image.icon_id, scale=5) row = self.layout.row(align=True) row.label(text=f"{ProfileData.data['total_profiles']} Named Profiles", icon="ITALIC") diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index f8acb4d7e1..178eaa49e0 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -53,6 +53,7 @@ from ifcopenshell.geom import ShapeElementType from bonsai.bim.module.project.data import LinksData from bonsai.bim.module.project.decorator import ProjectDecorator, ClippingPlaneDecorator from bonsai.bim.module.model.decorator import PolylineDecorator +from bonsai.bim.module.model.polyline import PolylineOperator from typing import Union @@ -819,6 +820,7 @@ class LoadProjectElements(bpy.types.Operator): tool.Project.set_default_context() tool.Project.set_default_modeling_dimensions() tool.Root.reload_grid_decorator() + tool.Root.reload_item_decorator() return {"FINISHED"} def get_decomposition_elements(self): @@ -2268,7 +2270,7 @@ if bpy.app.version >= (4, 1, 0): return True -class MeasureTool(bpy.types.Operator): +class MeasureTool(bpy.types.Operator, PolylineOperator): bl_idname = "bim.measure_tool" bl_options = {"REGISTER", "UNDO"} bl_label = "Measure Tool" @@ -2278,271 +2280,55 @@ class MeasureTool(bpy.types.Operator): return context.space_data.type == "VIEW_3D" def __init__(self): - self.mousemove_count = 0 - self.action_count = 0 - self.visible_objs = [] - self.objs_2d_bbox = [] - self.number_options = { - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - " ", - ".", - "+", - "-", - "*", - "/", - "'", - '"', - "=", - } - self.number_input = [] - self.number_output = "" - self.number_is_negative = False - self.is_input_on = False + super().__init__() + self.input_ui = tool.Polyline.create_input_ui(init_z=True) self.input_options = ["D", "A", "X", "Y", "Z"] - self.input_type = None - self.input_value_xy = [None, None] - self.input_panel = {"D": "", "A": "", "X": "", "Y": "", "Z": ""} - self.snap_angle = None - self.snapping_points = [] self.instructions = """TAB: Cycle Input - M: Modify Snap Point - C: Close - Backspace: Remove - X Y Z: Axis - S-(X Y Z): Plane - Shift: Lock axis -""" - - def recalculate_inputs(self, context): - if self.number_input: - is_valid, self.number_output = tool.Snap.validate_input(self.number_output, self.input_type) - self.input_panel[self.input_type] = self.number_output - if not is_valid: - self.report({"WARNING"}, "The number typed is not valid.") - return is_valid - else: - if self.input_type in {"X", "Y", "Z"}: - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - elif self.input_type in {"D", "A"}: - self.input_panel = PolylineDecorator.calculate_x_y_and_z(context) - # self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - else: - self.input_panel[self.input_type] = self.number_output - tool.Blender.update_viewport() - return is_valid + D: Distance Input + A: Angle Input + M: Modify Snap Point + C: Close Polyline + BACKSPACE: Remove Point + X, Y, Z: Choose Axis + S-X, S-Y, S-Z: Choose Plane + SHIFT: Lock axis + """ def modal(self, context, event): + if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: + return {"PASS_THROUGH"} - if not self.is_input_on: - if event.type == "MOUSEMOVE" or event.type == "INBETWEEN_MOUSEMOVE": - self.mousemove_count += 1 - self.is_input_on = False - self.input_type = None - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Snap.clear_snapping_ref() - tool.Blender.update_viewport() - else: - self.mousemove_count = 0 + self.handle_instructions(context) - if self.mousemove_count == 2: - self.objs_2d_bbox = [] - for obj in self.visible_objs: - self.objs_2d_bbox.append(tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj)) + self.handle_mouse_move(context, event) - if self.mousemove_count > 3: - detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox) - self.snapping_points = tool.Snap.select_snapping_points(context, event, detected_snaps) - PolylineDecorator.set_mouse_position(event) - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - tool.Blender.update_viewport() - return {"RUNNING_MODAL"} + self.choose_axis(event, z=True) - if event.value == "RELEASE" and event.type == "BACK_SPACE": - tool.Snap.remove_last_polyline_point() - tool.Blender.update_viewport() + self.choose_plane(event) - if event.value == "RELEASE" and event.type == "LEFTMOUSE": - tool.Snap.insert_polyline_point(self.input_panel) - tool.Blender.update_viewport() + self.handle_snap_selection(context, event) - if event.value == "PRESS" and event.type == "X": - tool.Snap.set_snap_axis_method("X") - tool.Blender.update_viewport() - - if event.value == "PRESS" and event.type == "Y": - tool.Snap.set_snap_axis_method("Y") - tool.Blender.update_viewport() - - if event.value == "PRESS" and event.type == "Z": - tool.Snap.set_snap_axis_method("Z") - tool.Blender.update_viewport() - - if event.value == "PRESS" and event.type == "C": - tool.Snap.close_polyline() - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if self.is_input_on and event.value == "PRESS" and event.type == "TAB": - self.recalculate_inputs(context) - index = self.input_options.index(self.input_type) - size = len(self.input_options) - self.input_type = self.input_options[((index + 1) % size)] - - self.number_input = self.input_panel[self.input_type] - self.number_input = list(self.number_input) - self.number_output = "".join(self.number_input) - if self.input_type != "A": - self.number_output = PolylineDecorator.format_input_panel_units(context, float(self.number_output)) - - self.input_panel[self.input_type] = self.number_output - - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if not self.is_input_on and event.value == "RELEASE" and event.type == "TAB": - self.recalculate_inputs(context) - self.is_input_on = True - self.input_type = "D" - - self.number_input = self.input_panel[self.input_type] - self.number_input = list(self.number_input) - self.number_output = "".join(self.number_input) - self.number_output = PolylineDecorator.format_input_panel_units(context, float(self.number_output)) - self.input_panel[self.input_type] = self.number_output - - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if not self.is_input_on and event.ascii in self.number_options: - self.recalculate_inputs(context) - self.is_input_on = True - self.input_type = "D" - self.number_input = [] - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if event.value == "RELEASE" and event.type in {"D", "A"}: - self.recalculate_inputs(context) - self.is_input_on = True - self.input_type = event.type - self.number_input = [] - self.input_panel[self.input_type] = "" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if self.input_type in self.input_options: - if (event.ascii in self.number_options) or (event.value == "RELEASE" and event.type == "BACK_SPACE"): - if event.type == "BACK_SPACE": - if len(self.number_input) <= 1: - self.number_input = [] - else: - self.number_input = self.number_input[:-1] - self.number_output = "".join(self.number_input) - self.input_panel[self.input_type] = self.number_output - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - else: - self.number_input.append(event.ascii) - self.number_output = "".join(self.number_input) - - if self.number_input: - self.input_panel[self.input_type] = self.number_output - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - - if not self.is_input_on and event.value == "RELEASE" and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}: + if ( + not self.tool_state.is_input_on + and event.value == "RELEASE" + and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"} + ): + context.workspace.status_text_set(text=None) PolylineDecorator.uninstall() tool.Snap.clear_polyline() tool.Blender.update_viewport() return {"FINISHED"} - if self.is_input_on and event.value == "RELEASE" and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"}: - is_valid = self.recalculate_inputs(context) - if is_valid: - tool.Snap.insert_polyline_point(self.input_panel) - self.is_input_on = False - self.input_type = None - self.number_input = [] - self.number_output = "" - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() + self.handle_keyboard_input(context, event) - if event.value == "PRESS" and event.type == "M": - self.snapping_points = tool.Snap.modify_snapping_point_selection(self.snapping_points) - PolylineDecorator.set_mouse_position(event) - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - tool.Blender.update_viewport() + self.handle_inserting_polyline(context, event) - if event.shift and event.value == "PRESS" and event.type == "X": - tool.Snap.set_use_default_container(False) - PolylineDecorator.set_use_default_container(False) - tool.Snap.cycle_snap_plane_method("YZ") - tool.Snap.set_snap_axis_method(None) - tool.Blender.update_viewport() - - if event.shift and event.value == "PRESS" and event.type == "Y": - tool.Snap.set_use_default_container(False) - PolylineDecorator.set_use_default_container(False) - tool.Snap.cycle_snap_plane_method("XZ") - tool.Snap.set_snap_axis_method(None) - tool.Blender.update_viewport() - - if event.shift and event.value == "PRESS" and event.type == "Z": - tool.Snap.set_use_default_container(False) - PolylineDecorator.set_use_default_container(False) - tool.Snap.cycle_snap_plane_method("XY") - tool.Snap.set_snap_axis_method(None) - tool.Blender.update_viewport() - - if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: - return {"PASS_THROUGH"} - - if self.is_input_on: - if event.value == "RELEASE" and event.type in {"ESC"}: - self.recalculate_inputs(context) - self.is_input_on = False - self.input_type = None - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - tool.Blender.update_viewport() - else: - if event.value == "RELEASE" and event.type in {"ESC"}: - tool.Snap.set_snap_plane_method(None) - tool.Snap.set_snap_axis_method(None) - PolylineDecorator.uninstall() - tool.Snap.clear_polyline() - tool.Blender.update_viewport() - return {"CANCELLED"} + result = self.handle_cancelation(context, event) + if result is not None: + return result return {"RUNNING_MODAL"} def invoke(self, context, event): - if context.space_data.type == "VIEW_3D": - PolylineDecorator.install(context) - tool.Snap.set_use_default_container(False) - PolylineDecorator.set_use_default_container(False) - tool.Snap.set_snap_plane_method(None) - tool.Snap.set_snap_axis_method(None) - PolylineDecorator.set_instructions(self.instructions) - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) - self.visible_objs = tool.Raycast.get_visible_objects(context) - for obj in self.visible_objs: - self.objs_2d_bbox.append(tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj)) - detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox) - self.snapping_points = tool.Snap.select_snapping_points(context, event, detected_snaps) - PolylineDecorator.set_mouse_position(event) - self.input_panel = PolylineDecorator.calculate_distance_and_angle(context, self.is_input_on) - tool.Blender.update_viewport() - context.window_manager.modal_handler_add(self) - return {"RUNNING_MODAL"} - else: - self.report({"WARNING"}, "Active space must be a View3d") - return {"CANCELLED"} + super().invoke(context, event) + return {"RUNNING_MODAL"} diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 1b476ba1e4..9004f5a84d 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -519,5 +519,7 @@ class BIM_PT_purge(Panel): def draw(self, context): layout = self.layout - layout.operator("bim.purge_unused_profiles") - layout.operator("bim.purge_unused_types") + layout.operator("bim.purge_unused_objects", text="Purge Unused Profiles").object_type = "PROFILE" + layout.operator("bim.purge_unused_objects", text="Purge Unused Types").object_type = "TYPE" + layout.operator("bim.purge_unused_objects", text="Purge Unused Styles").object_type = "STYLE" + layout.operator("bim.purge_unused_objects", text="Purge Unused Materials").object_type = "MATERIAL" diff --git a/src/bonsai/bonsai/bim/module/pset/data.py b/src/bonsai/bonsai/bim/module/pset/data.py index c22ee88053..e9768ff0ab 100644 --- a/src/bonsai/bonsai/bim/module/pset/data.py +++ b/src/bonsai/bonsai/bim/module/pset/data.py @@ -290,9 +290,18 @@ class ProfilePsetsData(Data): @classmethod def load(cls): - pprops = bpy.context.scene.BIMProfileProperties - ifc_definition_id = pprops.profiles[pprops.active_profile_index].ifc_definition_id - cls.data = {"psets": cls.psetqtos(tool.Ifc.get().by_id(ifc_definition_id), psets_only=True)} + active_profile = tool.Profile.get_active_profile_ui() + if active_profile: + ifc_definition_id = active_profile.ifc_definition_id + psets_data = cls.psetqtos(tool.Ifc.get().by_id(ifc_definition_id), psets_only=True) + else: + ifc_definition_id = 0 + psets_data = [] + + cls.data = { + "ifc_definition_id": ifc_definition_id, + "psets": psets_data, + } cls.is_loaded = True diff --git a/src/bonsai/bonsai/bim/module/pset/operator.py b/src/bonsai/bonsai/bim/module/pset/operator.py index 5870547bce..fe08585701 100644 --- a/src/bonsai/bonsai/bim/module/pset/operator.py +++ b/src/bonsai/bonsai/bim/module/pset/operator.py @@ -433,6 +433,14 @@ class BIM_OT_bulk_remove_psets(bpy.types.Operator, tool.Ifc.Operator): class AddProposedProp(bpy.types.Operator): bl_idname = "bim.add_proposed_prop" bl_label = "Add Proposed Prop" + bl_description = ( + "Add proposed property to the custom property set.\n\n" + "Property type will be deduced from the provided value. Possible types:\n" + "- provide an integer or a float to create integer/real property\n" + "- 'true', 'false' to add a boolean property\n" + "- 'null' or '' (empty value) to add a null property\n" + "- any other value will be added as a string property" + ) bl_options = {"REGISTER", "UNDO"} obj: bpy.props.StringProperty() obj_type: bpy.props.StringProperty() @@ -440,5 +448,8 @@ class AddProposedProp(bpy.types.Operator): prop_value: bpy.props.StringProperty() def execute(self, context): - core.add_proposed_prop(tool.Pset, self.obj, self.obj_type, self.prop_name, self.prop_value) + res = core.add_proposed_prop(tool.Pset, self.obj, self.obj_type, self.prop_name, self.prop_value) + if res: + self.report({"ERROR"}, res) + return {"CANCELLED"} return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/pset/prop.py b/src/bonsai/bonsai/bim/module/pset/prop.py index 074ac3d73f..7804521221 100644 --- a/src/bonsai/bonsai/bim/module/pset/prop.py +++ b/src/bonsai/bonsai/bim/module/pset/prop.py @@ -243,6 +243,7 @@ class PsetProperties(PropertyGroup): properties: CollectionProperty(name="Properties", type=IfcProperty) pset_name: EnumProperty(items=get_pset_name, name="Pset Name") qto_name: EnumProperty(items=get_qto_name, name="Qto Name") + # Proposed property. prop_name: StringProperty(name="Property Name", default="MyProperty") prop_value: StringProperty(name="Property Value", default="Some Value") diff --git a/src/bonsai/bonsai/bim/module/pset/ui.py b/src/bonsai/bonsai/bim/module/pset/ui.py index fd01a3b6af..3177a9ae31 100644 --- a/src/bonsai/bonsai/bim/module/pset/ui.py +++ b/src/bonsai/bonsai/bim/module/pset/ui.py @@ -664,7 +664,15 @@ class BIM_PT_profile_psets(Panel): return False def draw(self, context): - if not ProfilePsetsData.is_loaded: + active_profile = tool.Profile.get_active_profile_ui() + + if not active_profile: + return + + if ( + not ProfilePsetsData.is_loaded + or active_profile.ifc_definition_id != ProfilePsetsData.data["ifc_definition_id"] + ): ProfilePsetsData.load() props = context.scene.ProfilePsetProperties diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 5352ff144c..f0c117528b 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -414,7 +414,7 @@ class ToggleGrids(bpy.types.Operator, tool.Ifc.Operator): is_visible: bpy.props.BoolProperty(name="Is Visible", default=False, options={"SKIP_SAVE"}) def _execute(self, context): - for element in (tool.Ifc.get().by_type("IfcGrid") + tool.Ifc.get().by_type("IfcGridAxis")): + for element in tool.Ifc.get().by_type("IfcGrid") + tool.Ifc.get().by_type("IfcGridAxis"): if obj := tool.Ifc.get_object(element): obj.hide_set(not self.is_visible) diff --git a/src/bonsai/bonsai/bim/module/spatial/prop.py b/src/bonsai/bonsai/bim/module/spatial/prop.py index c2b1def5b6..161130b877 100644 --- a/src/bonsai/bonsai/bim/module/spatial/prop.py +++ b/src/bonsai/bonsai/bim/module/spatial/prop.py @@ -31,9 +31,11 @@ from bpy.props import ( CollectionProperty, ) import bonsai.tool as tool +import bonsai.bim.handler import bonsai.core.geometry import ifcopenshell import ifcopenshell.util.element +import ifcopenshell.util.unit def get_subelement_class(self, context): @@ -124,6 +126,16 @@ def update_spatial_is_locked(self, context): tool.Geometry.lock_object(obj) else: tool.Geometry.unlock_object(obj) + # Need to update ViewportData.mode. + bonsai.bim.handler.refresh_ui_data() + + +def update_spatial_is_visible(self: "BIMSpatialDecompositionProperties", context: bpy.types.Context) -> None: + bpy.ops.bim.toggle_spatial_elements(is_visible=self.is_visible) + + +def update_grid_is_visible(self: "BIMGridProperties", context: bpy.types.Context) -> None: + bpy.ops.bim.toggle_grids(is_visible=self.is_visible) def poll_container_obj(self, obj): @@ -173,7 +185,18 @@ class Element(PropertyGroup): class BIMSpatialDecompositionProperties(PropertyGroup): - is_locked: BoolProperty(name="Is Locked", default=True, update=update_spatial_is_locked) + is_locked: BoolProperty( + name="Is Locked", + description="Prevent all spatial elements from being edited, removed, duplicated", + default=True, + update=update_spatial_is_locked, + ) + is_visible: BoolProperty( + name="Is Visible", + description="Show or hide spatial elements, such as buildings, sites, etc", + default=True, + update=update_spatial_is_visible, + ) container_filter: StringProperty(name="Container Filter", default="", options={"TEXTEDIT_UPDATE"}) containers: CollectionProperty(name="Containers", type=BIMContainer) contracted_containers: StringProperty(name="Contracted containers", default="[]") @@ -210,5 +233,16 @@ class BIMSpatialDecompositionProperties(PropertyGroup): class BIMGridProperties(PropertyGroup): - is_locked: BoolProperty(name="Is Locked", default=True, update=update_grid_is_locked) + is_locked: BoolProperty( + name="Is Locked", + description="Prevent all grids and grid axes from being edited, removed, duplicated", + default=True, + update=update_grid_is_locked, + ) + is_visible: BoolProperty( + name="Is Visible", + description="Show or hide grids and grid axes", + default=True, + update=update_grid_is_visible, + ) grid_axes: CollectionProperty(name="Grid Axes", type=ObjProperty) diff --git a/src/bonsai/bonsai/bim/module/spatial/ui.py b/src/bonsai/bonsai/bim/module/spatial/ui.py index 39e810ed8d..a1d25b8c6f 100644 --- a/src/bonsai/bonsai/bim/module/spatial/ui.py +++ b/src/bonsai/bonsai/bim/module/spatial/ui.py @@ -103,12 +103,13 @@ class BIM_PT_spatial_decomposition(Panel): return tool.Ifc.get() def draw_header(self, context): + props = context.scene.BIMSpatialDecompositionProperties row = self.layout.row(align=True) row.label(text="") # empty text occupies the left of the row - row.operator("bim.toggle_spatial_elements", text="", icon="HIDE_OFF").is_visible = True - row.operator("bim.toggle_spatial_elements", text="", icon="HIDE_ON").is_visible = False - icon = "VIEW_LOCKED" if context.scene.BIMSpatialDecompositionProperties.is_locked else "VIEW_UNLOCKED" - row.prop(context.scene.BIMSpatialDecompositionProperties, "is_locked", text="", icon=icon) + icon = "HIDE_OFF" if props.is_visible else "HIDE_ON" + row.prop(props, "is_visible", text="", icon=icon) + icon = "VIEW_LOCKED" if props.is_locked else "VIEW_UNLOCKED" + row.prop(props, "is_locked", text="", icon=icon) def draw(self, context): if not SpatialDecompositionData.is_loaded: @@ -218,12 +219,13 @@ class BIM_PT_grids(Panel): self.layout.row().operator("mesh.add_grid", icon="ADD", text="Add Grids") def draw_header(self, context): + props = context.scene.BIMGridProperties row = self.layout.row(align=True) row.label(text="") # empty text occupies the left of the row - row.operator("bim.toggle_grids", text="", icon="HIDE_OFF").is_visible = True - row.operator("bim.toggle_grids", text="", icon="HIDE_ON").is_visible = False - icon = "VIEW_LOCKED" if context.scene.BIMGridProperties.is_locked else "VIEW_UNLOCKED" - row.prop(context.scene.BIMGridProperties, "is_locked", text="", icon=icon) + icon = "HIDE_OFF" if props.is_visible else "HIDE_ON" + row.prop(props, "is_visible", text="", icon=icon) + icon = "VIEW_LOCKED" if props.is_locked else "VIEW_UNLOCKED" + row.prop(props, "is_locked", text="", icon=icon) class BIM_UL_containers_manager(UIList): diff --git a/src/bonsai/bonsai/bim/module/style/operator.py b/src/bonsai/bonsai/bim/module/style/operator.py index 22dd98b7ba..0a1bc04598 100644 --- a/src/bonsai/bonsai/bim/module/style/operator.py +++ b/src/bonsai/bonsai/bim/module/style/operator.py @@ -356,14 +356,14 @@ class BrowseExternalStyle(bpy.types.Operator): return {"FINISHED"} -class ActivateExternalStyle(bpy.types.Operator, tool.Ifc.Operator): +class ActivateExternalStyle(bpy.types.Operator): bl_idname = "bim.activate_external_style" bl_label = "Activate External Style" bl_options = {"REGISTER", "UNDO", "INTERNAL"} material_name: bpy.props.StringProperty(name="Material Name", default="") - def _execute(self, context): + def execute(self, context): if not self.material_name: material = context.active_object.active_material else: @@ -403,6 +403,7 @@ class ActivateExternalStyle(bpy.types.Operator, tool.Ifc.Operator): if material.use_nodes: tool.Blender.copy_node_graph(material, db["data_block"]) bpy.data.materials.remove(db["data_block"]) + return {"FINISHED"} def copy_material_attributes(self, source, target): ID_properties = bpy.types.ID.bl_rna.properties @@ -444,33 +445,37 @@ class ActivateExternalStyle(bpy.types.Operator, tool.Ifc.Operator): set_prop(prop_name) -class DisableEditingStyles(bpy.types.Operator, tool.Ifc.Operator): +class DisableEditingStyles(bpy.types.Operator): bl_idname = "bim.disable_editing_styles" bl_options = {"REGISTER", "UNDO"} bl_label = "Disable Editing Styles" - def _execute(self, context): + def execute(self, context): core.disable_editing_styles(tool.Style) + return {"FINISHED"} -class LoadStyles(bpy.types.Operator, tool.Ifc.Operator): +class LoadStyles(bpy.types.Operator): bl_idname = "bim.load_styles" bl_label = "Load Styles" bl_options = {"REGISTER", "UNDO"} style_type: bpy.props.StringProperty() - def _execute(self, context): - core.load_styles(tool.Style, style_type=self.style_type) + def execute(self, context): + style_type = self.style_type if self.style_type else context.scene.BIMStylesProperties.style_type + core.load_styles(tool.Style, style_type=style_type) + return {"FINISHED"} -class SelectByStyle(bpy.types.Operator, tool.Ifc.Operator): +class SelectByStyle(bpy.types.Operator): bl_idname = "bim.select_by_style" bl_label = "Select By Style" bl_options = {"REGISTER", "UNDO"} style: bpy.props.IntProperty() - def _execute(self, context): + def execute(self, context): core.select_by_style(tool.Style, tool.Spatial, style=tool.Ifc.get().by_id(self.style)) + return {"FINISHED"} class ChooseTextureMapPath(bpy.types.Operator): @@ -633,14 +638,14 @@ class AddPresentationStyle(bpy.types.Operator, tool.Ifc.Operator): core.load_styles(tool.Style, style_type=props.style_type) -class EnableEditingSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator): +class EnableEditingSurfaceStyle(bpy.types.Operator): bl_idname = "bim.enable_editing_surface_style" bl_label = "Enable Editing Surface Style" bl_options = {"REGISTER", "UNDO"} style: bpy.props.IntProperty(default=0) ifc_class: bpy.props.StringProperty(default="") - def _execute(self, context): + def execute(self, context): props = bpy.context.scene.BIMStylesProperties style = tool.Ifc.get().by_id(self.style) props.is_editing_style = self.style @@ -678,6 +683,7 @@ class EnableEditingSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator): and active_style_type != "Shading" ): tool.Style.switch_shading(material, "Shading") + return {"FINISHED"} class EditSurfaceStyle(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/type/__init__.py b/src/bonsai/bonsai/bim/module/type/__init__.py index 445c9e1e28..f0e5546740 100644 --- a/src/bonsai/bonsai/bim/module/type/__init__.py +++ b/src/bonsai/bonsai/bim/module/type/__init__.py @@ -26,7 +26,6 @@ classes = ( operator.DisableEditingType, operator.DuplicateType, operator.EnableEditingType, - operator.PurgeUnusedTypes, operator.RemoveType, operator.RenameType, operator.SelectSimilarType, diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index c72cebcf71..605ab97c25 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -515,12 +515,12 @@ class RenameType(bpy.types.Operator, tool.Ifc.Operator): self.layout.prop(self, "name") -class AutoRenameOccurrences(bpy.types.Operator, tool.Ifc.Operator): +class AutoRenameOccurrences(bpy.types.Operator): bl_idname = "bim.auto_rename_occurrences" bl_label = "Auto Rename Occurrences" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def execute(self, context): obj = context.active_object element_type = tool.Ifc.get_entity(obj) if element_type and element_type.is_a("IfcTypeObject"): @@ -529,6 +529,7 @@ class AutoRenameOccurrences(bpy.types.Operator, tool.Ifc.Operator): occurrence.Name = tool.Model.generate_occurrence_name(element_type, occurrence.is_a()) if obj: tool.Root.set_object_name(obj, occurrence) + return {"FINISHED"} class DuplicateType(bpy.types.Operator, tool.Ifc.Operator): @@ -555,13 +556,3 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator): context.scene.BIMModelProperties.ifc_class = new.is_a() context.scene.BIMModelProperties.relating_type_id = str(new_obj.BIMObjectProperties.ifc_definition_id) return {"FINISHED"} - - -class PurgeUnusedTypes(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.purge_unused_types" - bl_label = "Purge Unused Types" - bl_options = {"REGISTER", "UNDO"} - - def _execute(self, context): - purged_types = core.purge_unused_types(tool.Ifc, tool.Type, tool.Geometry) - self.report({"INFO"}, f"{purged_types} types were purged.") diff --git a/src/bonsai/bonsai/core/profile.py b/src/bonsai/bonsai/core/profile.py index d8cf537345..a0ee00b6de 100644 --- a/src/bonsai/bonsai/core/profile.py +++ b/src/bonsai/bonsai/core/profile.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: def purge_unused_profiles(ifc: tool.Ifc, profile: tool.Profile) -> int: - """Purge profiles that have no invserses. + """Purge profiles that have no inverses. :return: Number of removed profiles. """ diff --git a/src/bonsai/bonsai/core/pset.py b/src/bonsai/bonsai/core/pset.py index c2b87836df..c96e5177ff 100644 --- a/src/bonsai/bonsai/core/pset.py +++ b/src/bonsai/bonsai/core/pset.py @@ -89,9 +89,12 @@ def enable_pset_editing( pset_tool.enable_proposed_pset(props, pset_name, pset_type, has_template) -def add_proposed_prop(pset: tool.Pset, obj_name: str, obj_type: tool.Ifc.OBJECT_TYPE, name: str, value: Any) -> None: +def add_proposed_prop( + pset: tool.Pset, obj_name: str, obj_type: tool.Ifc.OBJECT_TYPE, name: str, value: Any +) -> Union[None, str]: props = pset.get_pset_props(obj_name, obj_type) - pset.add_proposed_property(name, pset.cast_string_to_primitive(value), props) + res = pset.add_proposed_property(name, pset.cast_string_to_primitive(value), props) + return res def unshare_pset( diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index e3566b72cf..dc9a8a81a2 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -581,6 +581,16 @@ class Patch: def run_migrate_patch(cls, infile, outfile, schema): pass +@interface +class Polyline: + def create_input_ui(cls, init_z=False, init_area=False): pass + def create_tool_state(cls): pass + def calculate_distance_and_angle(cls, context, input_ui, tool_state): pass + def calculate_area(cls, context, input_ui): pass + def calculate_x_y_and_z(cls, context, input_ui, tool_state): pass + def validate_input(cls, input_number, input_type): pass + + @interface class Owner: def add_address_attribute(cls, name): pass @@ -668,10 +678,12 @@ class Qto: def get_rounded_value(cls, new_quantity): pass def set_qto_result(cls, result): pass + @interface class Raycast: pass + @interface class Resource: def clear_productivity_data(cls, props): pass @@ -945,10 +957,12 @@ class Spatial: class Covering: def get_z_from_ceiling_height(cls): pass + @interface class Snap: pass + @interface class Structural: def disable_editing_structural_analysis_model(cls): pass diff --git a/src/bonsai/bonsai/tool/__init__.py b/src/bonsai/bonsai/tool/__init__.py index a7d28cab44..16a64716e4 100644 --- a/src/bonsai/bonsai/tool/__init__.py +++ b/src/bonsai/bonsai/tool/__init__.py @@ -44,6 +44,7 @@ from bonsai.tool.model import Model from bonsai.tool.nest import Nest from bonsai.tool.owner import Owner from bonsai.tool.patch import Patch +from bonsai.tool.polyline import Polyline from bonsai.tool.project import Project from bonsai.tool.profile import Profile from bonsai.tool.pset import Pset diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 5ea0cb05e8..7d15d5772c 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -803,7 +803,7 @@ class Blender(bonsai.core.tool.Blender): return collections_mapping @classmethod - def is_editable(cls, obj): + def is_editable(cls, obj: bpy.types.Object) -> bool: if obj.type not in cls.OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE: return False if not (element := tool.Ifc.get_entity(obj)): diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index 8d9945a4ad..8b38502c14 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -295,6 +295,14 @@ class Cost(bonsai.core.tool.Cost): unit = cls.get_quantity_unit_symbol(quantity) return selected_quantitites, unit + @classmethod + def get_assigned_product(cls, cost_item, quantity): + assigned_products = cls.get_cost_item_assignments(cost_item, filter_by_type="PRODUCT", is_deep=False) + for product in assigned_products: + assigned_quantities, _ = cls.get_assigned_quantities(cost_item, product) + if quantity in assigned_quantities: + return product + @classmethod def get_products(cls, related_object_type: RELATED_OBJECT_TYPE) -> list[ifcopenshell.entity_instance]: if related_object_type == "PRODUCT": @@ -859,7 +867,32 @@ class Cost(bonsai.core.tool.Cost): bpy.ops.bim.connect_websocket_server(page="costing") cost_schedule_data = cls.create_cost_schedule_json(cost_chedule) tool.Web.send_webui_data( - data={"cost_items": cost_schedule_data, "cost_schedule_id": cost_chedule.id()}, + data={"cost_items": cost_schedule_data, "cost_schedule_id": cost_chedule.id(), "currency": cls.currency()}, data_key="cost_items", event="cost_items", ) + + @classmethod + def get_cost_quantities(cls, cost_item: ifcopenshell.entity_instance) -> dict: + results = { + "quantities": [], + "unit_symbol": None, + } + if not cost_item: + return results + results["quantity_type"] = cost_item.CostQuantities[0].is_a() if cost_item.CostQuantities else None + unit = ( + ifcopenshell.util.unit.get_property_unit(cost_item.CostQuantities[0], tool.Ifc.get()) + if cost_item.CostQuantities + else None + ) + if unit: + results["unit_symbol"] = ifcopenshell.util.unit.get_unit_symbol(unit) + for quantity in cost_item.CostQuantities or []: + assigned_product = cls.get_assigned_product(cost_item, quantity) + info = quantity.get_info() + info["fromProduct"] = assigned_product.get_info(recursive=True) if assigned_product else None + results["quantities"].append(info) + if results["quantity_type"] == "IfcQuantityCount": + results["unit_symbol"] = "U" + return results diff --git a/src/bonsai/bonsai/tool/debug.py b/src/bonsai/bonsai/tool/debug.py index 99bf884aa9..5451cae5b6 100644 --- a/src/bonsai/bonsai/tool/debug.py +++ b/src/bonsai/bonsai/tool/debug.py @@ -97,3 +97,15 @@ class Debug(bonsai.core.tool.Debug): print(f"{class_string: <50} {unused[ifc_class]: >5}") return sum(unused.values()) + + @classmethod + def purge_unused_class(cls, ifc_class: str) -> int: + ifc_file = tool.Ifc.get() + elements = ifc_file.by_type(ifc_class) + i = 0 + for element in elements: + if ifc_file.get_total_inverses(element) != 0: + continue + ifcopenshell.util.element.remove_deep(ifc_file, element) + i += 1 + return i diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index de4c983495..cdf5fbc49d 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -1855,6 +1855,8 @@ class Drawing(bonsai.core.tool.Drawing): element_obj_names = set() for element in filtered_elements: obj = tool.Ifc.get_object(element) + if not obj: + continue current_representation = tool.Geometry.get_active_representation(obj) if current_representation: subcontext = current_representation.ContextOfItems diff --git a/src/bonsai/bonsai/tool/polyline.py b/src/bonsai/bonsai/tool/polyline.py new file mode 100644 index 0000000000..4c66316bd4 --- /dev/null +++ b/src/bonsai/bonsai/tool/polyline.py @@ -0,0 +1,406 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2024 Bruno Perdigão +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import bpy +import bonsai.core.tool +import bonsai.tool as tool +from bonsai.bim.module.drawing.helper import format_distance +from dataclasses import dataclass +from lark import Lark, Transformer +from math import radians +from mathutils import Vector +from typing import Optional + + +class Polyline(bonsai.core.tool.Polyline): + + @dataclass + class PolylineUI: + _D: str = "" + _A: str = "" + _X: str = "" + _Y: str = "" + _Z: Optional[str] = None + _AREA: Optional[str] = None + init_z: bool = False + init_area: bool = False + + def __post_init__(self): + if self.init_z: + self._Z = "" + if self.init_area: + self._AREA = "" + + def set_value(self, attribute_name, value): + value = str(value) + setattr(self, f"_{attribute_name}", value) + + def get_text_value(self, attribute_name): + value = getattr(self, f"_{attribute_name}") + return value + + def get_number_value(self, attribute_name): + value = getattr(self, f"_{attribute_name}") + if value: + return float(value) + else: + return value + + def get_formatted_value(self, attribute_name): + value = self.get_number_value(attribute_name) + context = bpy.context + if value is None: + return None + if attribute_name == "A": + value = float(self.get_text_value(attribute_name)) + return f"{value:.2f}" + else: + return self.format_input_ui_units(context, value) + + def format_input_ui_units(cls, context, value): + unit_system = tool.Drawing.get_unit_system() + if unit_system == "IMPERIAL": + precision = context.scene.DocProperties.imperial_precision + factor = 3.28084 + else: + precision = None + factor = 1 + if context.scene.unit_settings.length_unit == "MILLIMETERS": + factor = 1000 + + return format_distance(value * factor, precision=precision, suppress_zero_inches=True, in_unit_length=True) + + @dataclass + class ToolState: + use_default_container: bool = None + snap_angle: float = None + is_input_on: bool = None + # angle_axis_start: Vector + # angle_axis_end: Vector + axis_method: str = None + plane_method: str = None + instructions: str = """TAB: Cycle Input + M: Modify Snap Point + C: Close + Backspace: Remove + X Y: Axis + Shift: Lock axis + """ + snap_info: str = None + mode: str = None + input_type: str = None + + @classmethod + def create_input_ui(cls, init_z=False, init_area=False): + return cls.PolylineUI(init_z=init_z, init_area=init_area) + + @classmethod + def create_tool_state(cls): + return cls.ToolState() + + @classmethod + def calculate_distance_and_angle(cls, context, input_ui, tool_state): + + try: + polyline_data = context.scene.BIMModelProperties.polyline_point + default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + last_point_data = polyline_data[len(polyline_data) - 1] + except: + default_container_elevation = 0 + last_point_data = None + + snap_prop = context.scene.BIMModelProperties.snap_mouse_point[0] + + if last_point_data: + last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) + else: + last_point = Vector((0, 0, 0)) + + if tool_state.is_input_on: + if tool_state.use_default_container: + snap_vector = Vector( + (input_ui.get_number_value("X"), input_ui.get_number_value("Y"), default_container_elevation) + ) + else: + snap_vector = Vector( + (input_ui.get_number_value("X"), input_ui.get_number_value("Y"), input_ui.get_number_value("Z")) + ) + else: + if tool_state.use_default_container: + snap_vector = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) + else: + snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + + second_to_last_point = None + if len(polyline_data) > 1: + second_to_last_point_data = polyline_data[len(polyline_data) - 2] + second_to_last_point = Vector( + (second_to_last_point_data.x, second_to_last_point_data.y, second_to_last_point_data.z) + ) + else: + # Creates a fake "second to last" point away from the first point but in the same x axis + # this allows to calculate the angle relative to x axis when there is only one point + second_to_last_point = Vector((last_point.x + 1000, last_point.y, last_point.z)) + + distance = (snap_vector - last_point).length + if distance > 0: + angle = tool.Cad.angle_3_vectors( + second_to_last_point, last_point, snap_vector, new_angle=None, degrees=True + ) + + # Round angle to the nearest 0.05 + angle = round(angle / 0.05) * 0.05 + + if input_ui: + input_ui.set_value("X", snap_vector.x) + input_ui.set_value("Y", snap_vector.y) + if input_ui.get_number_value("Z") is not None: + input_ui.set_value("Z", snap_vector.z) + + input_ui.set_value("D", distance) + input_ui.set_value("A", angle) + return + + return + + @classmethod + def calculate_area(cls, context, input_ui): + try: + polyline_data = context.scene.BIMModelProperties.polyline_point + except: + return input_ui + + if len(polyline_data) < 3: + return input_ui + + points = [] + for data in polyline_data: + points.append(Vector((data.x, data.y, data.z))) + + if points[0] == points[-1]: + points = points[1:] + + # TODO move this to CAD + # Calculate the normal vector of the plane formed by the first three vertices + v1, v2, v3 = points[:3] + normal = (v2 - v1).cross(v3 - v1).normalized() + + # Check if all points are coplanar + is_coplanar = True + tolerance = 1e-6 # Adjust this value as needed + for v in points: + if abs((v - v1).dot(normal)) > tolerance: + is_coplanar = False + + if is_coplanar: + area = 0 + for i in range(len(points)): + j = (i + 1) % len(points) + area += points[i].cross(points[j]).dot(normal) + + area = abs(area) / 2 + else: + area = 0 + + if input_ui.get_text_value("A") is not None: + input_ui.set_value("A", area) + return + + @classmethod + def calculate_x_y_and_z(cls, context, input_ui, tool_state): + try: + polyline_data = context.scene.BIMModelProperties.polyline_point + default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + last_point_data = polyline_data[len(polyline_data) - 1] + last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.z)) + except: + default_container_elevation = 0 + last_point = Vector((0, 0, 0)) + + snap_prop = context.scene.BIMModelProperties.snap_mouse_point[0] + snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + + if tool_state.use_default_container: + snap_vector = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) + else: + snap_vector = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + + if len(polyline_data) > 1: + second_to_last_point_data = polyline_data[len(polyline_data) - 2] + second_to_last_point = Vector( + (second_to_last_point_data.x, second_to_last_point_data.y, second_to_last_point_data.z) + ) + else: + # Creates a fake "second to last" point away from the first point but in the same x axis + # this allows to calculate the angle relative to x axis when there is only one point + second_to_last_point = Vector((last_point.x + 1000, last_point.y, last_point.z)) + + distance = input_ui.get_number_value("D") + + if distance < 0 or distance > 0: + angle = radians(input_ui.get_number_value("A")) + + rot_vector = tool.Cad.angle_3_vectors(second_to_last_point, last_point, snap_vector, angle, degrees=True) + + coords = rot_vector * distance + last_point + + x = coords[0] + y = coords[1] + z = coords[2] + if input_ui: + input_ui.set_value("X", x) + input_ui.set_value("Y", y) + if input_ui.get_number_value("Z") is not None: + input_ui.set_value("Z", z) + + return + + input_ui.set_value("X", last_point.x) + input_ui.set_value("Y", last_point.y) + if input_ui.get_number_value("Z") is not None: + input_ui.set_value("Z", last_point.z) + + return + + @classmethod + def validate_input(cls, input_number, input_type): + + grammar_imperial = """ + start: (FORMULA dim expr) | dim + dim: imperial + + FORMULA: "=" + + imperial: feet? "-"? inches? + feet: NUMBER? "-"? fraction? "'" + inches: NUMBER? "-"? fraction? "\\"" + fraction: NUMBER "/" NUMBER + + expr: (ADD | SUB) dim | (MUL | DIV) NUMBER + + NUMBER: /-?\\d+(?:\\.\\d+)?/ + ADD: "+" + SUB: "-" + MUL: "*" + DIV: "/" + + %ignore " " + """ + + grammar_metric = """ + start: FORMULA? dim expr? + dim: metric + + FORMULA: "=" + + metric: NUMBER + + expr: (ADD | SUB | MUL | DIV) dim + + NUMBER: /-?\\d+(?:\\.\\d+)?/ + ADD: "+" + SUB: "-" + MUL: "*" + DIV: "/" + + %ignore " " + """ + + class InputTransform(Transformer): + def NUMBER(self, n): + return float(n) + + def fraction(self, numbers): + return numbers[0] / numbers[1] + + def inches(self, args): + if len(args) > 1: + result = args[0] + args[1] + else: + result = args[0] + return result / 12 + + def feet(self, args): + return args[0] + + def imperial(self, args): + if len(args) > 1: + if args[0] <= 0: + result = args[0] - args[1] + else: + result = args[0] + args[1] + else: + result = args[0] + return result + + def metric(self, args): + return args[0] + + def dim(self, args): + return args[0] + + def expr(self, args): + op = args[0] + value = float(args[1]) + if op == "+": + return lambda x: x + value + elif op == "-": + return lambda x: x - value + elif op == "*": + return lambda x: x * value + elif op == "/": + return lambda x: x / value + + def FORMULA(cls, args): + return args[0] + + def start(self, args): + i = 0 + if args[0] == "=": + i += 1 + else: + if len(args) > 1: + raise ValueError("Invalid input.") + dimension = args[i] + if len(args) > i + 1: + expression = args[i + 1] + return expression(dimension) * factor + else: + return dimension * factor + + try: + if bpy.context.scene.unit_settings.system == "IMPERIAL": + parser = Lark(grammar_imperial) + factor = 0.3048 + else: + parser = Lark(grammar_metric) + factor = 1 + if bpy.context.scene.unit_settings.length_unit == "MILLIMETERS": + factor = 0.001 + + if input_type == "A": + parser = Lark(grammar_metric) + factor = 1 + + parse_tree = parser.parse(input_number) + + transformer = InputTransform() + result = transformer.transform(parse_tree) + return True, str(result) + except: + return False, "0" diff --git a/src/bonsai/bonsai/tool/profile.py b/src/bonsai/bonsai/tool/profile.py index 859f8e4a3b..6a8d0a3b6d 100644 --- a/src/bonsai/bonsai/tool/profile.py +++ b/src/bonsai/bonsai/tool/profile.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import bpy import ifcopenshell import ifcopenshell.api.profile import ifcopenshell.geom @@ -39,7 +40,11 @@ class Profile(bonsai.core.tool.Profile): settings = ifcopenshell.geom.settings() settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) shape = ifcopenshell.geom.create_shape(settings, profile) + verts = shape.verts + if not verts: + raise RuntimeError("Profile shape has no vertices, it probably is invalid.") + edges = shape.edges grouped_verts = [[verts[i], verts[i + 1]] for i in range(0, len(verts), 3)] @@ -104,3 +109,111 @@ class Profile(bonsai.core.tool.Profile): # In UI unnamed profiles are not available, so we don't handle them. new_profile.ProfileName = profile.ProfileName + "_copy" return new_profile + + @classmethod + def get_active_profile_ui(cls) -> Union[bpy.types.PropertyGroup, None]: + props = bpy.context.scene.BIMProfileProperties + index = props.active_profile_index + if len(props.profiles) > index >= 0: + return props.profiles[index] + + # Lengths are in meters. + DEFAULT_PROFILE_ATTRS = { + "IfcCircleProfileDef": { + "Radius": 0.05, + }, + # TODO: test after debug + "IfcAsymmetricIShapeProfileDef": { + "BottomFlangeWidth": 0.1, + "BottomFlangeThickness": 0.01, + "BottomFlangeFilletRadius": 0.01, + "OverallDepth": 0.1, + "WebThickness": 0.005, + "TopFlangeWidth": 0.075, + "TopFlangeThickness": 0.01, + "TopFlangeFilletRadius": 0.01, + }, + "IfcCShapeProfileDef": { + "Depth": 0.1, + "Width": 0.05, + "WallThickness": 0.01, + "Girth": 0.01, + }, + # 101.6-10.0 + "IfcCircleHollowProfileDef": { + "WallThickness": 0.01, + }, + # TODO: check shape after fixing crash + # "IfcEllipseProfileDef": { + # "SemiAxis1": 0.1, + # "SemiAxis2": 0.1, + # }, + # HEA100 + "IfcIShapeProfileDef": { + "OverallWidth": 0.1, + "OverallDepth": 0.1, + "WebThickness": 0.005, + "FlangeThickness": 0.01, + "FilletRadius": 0.01, + }, + # LNP100x10 + "IfcLShapeProfileDef": { + "Depth": 0.1, + "Thickness": 0.01, + "FilletRadius": 0.012, + "EdgeRadius": 0.01, + }, + "IfcRectangleProfileDef": { + "XDim": 0.1, + "YDim": 0.1, + }, + "IfcRoundedRectangleProfileDef": { + "RoundingRadius": 0.01, + }, + # 100-10.0 + "IfcRectangleHollowProfileDef": { + "WallThickness": 0.01, + "InnerFilletRadius": 0.01, + "OuterFilletRadius": 0.01, + }, + "IfcTShapeProfileDef": { + "Depth": 0.1, + "FlangeWidth": 0.05, + "WebThickness": 0.005, + "FlangeThickness": 0.009, + }, + "IfcTrapeziumProfileDef": { + "BottomXDim": 0.1, + "TopXDim": 0.08, + "YDim": 0.05, + "TopXOffset": 0.01, + }, + # UAP100 + "IfcUShapeProfileDef": { + "Depth": 0.1, + "FlangeWidth": 0.05, + "WebThickness": 0.005, + "FlangeThickness": 0.009, + }, + # ZNP100 + "IfcZShapeProfileDef": { + "Depth": 0.1, + "FlangeWidth": 0.05, + "WebThickness": 0.007, + "FlangeThickness": 0.01, + }, + } + + @classmethod + def set_default_profile_attrs(cls, profile: ifcopenshell.entity_instance) -> None: + """Set default profile attributes to keep profile valid.""" + class_match = False + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + for ifc_class, params in cls.DEFAULT_PROFILE_ATTRS.items(): + if profile.is_a(ifc_class): + class_match = True + for key, value in params.items(): + setattr(profile, key, value / si_conversion) + + if not class_match: + raise ValueError(f"Unable to set default profile parameters for {profile.is_a()}.") diff --git a/src/bonsai/bonsai/tool/pset.py b/src/bonsai/bonsai/tool/pset.py index b4d9e936d6..dcfa111872 100644 --- a/src/bonsai/bonsai/tool/pset.py +++ b/src/bonsai/bonsai/tool/pset.py @@ -297,9 +297,9 @@ class Pset(bonsai.core.tool.Pset): return bonsai.bim.schema.ifc.psetqto.get_by_name(name) @classmethod - def add_proposed_property(cls, name: str, value: Any, props: bpy.types.PropertyGroup) -> None: + def add_proposed_property(cls, name: str, value: Any, props: bpy.types.PropertyGroup) -> Union[None, str]: if props.properties.get(name): - return + return f"Property '{name}' already exists." prop = props.properties.add() prop.name = name metadata = prop.metadata @@ -327,4 +327,3 @@ class Pset(bonsai.core.tool.Pset): return value except: return value - return value diff --git a/src/bonsai/bonsai/tool/root.py b/src/bonsai/bonsai/tool/root.py index 13a47b386c..0522ab6ea0 100644 --- a/src/bonsai/bonsai/tool/root.py +++ b/src/bonsai/bonsai/tool/root.py @@ -28,6 +28,7 @@ import bonsai.core.geometry import bonsai.tool as tool from typing import Union, Optional, Any from bonsai.bim.module.spatial.decorator import GridDecorator +from bonsai.bim.module.geometry.decorator import ItemDecorator class Root(bonsai.core.tool.Root): @@ -214,6 +215,12 @@ class Root(bonsai.core.tool.Root): new.obj = obj GridDecorator.install(bpy.context) + @classmethod + def reload_item_decorator(cls) -> None: + item_objs = bpy.context.scene.BIMGeometryProperties.item_objs + item_objs.clear() + ItemDecorator.install(bpy.context) + @classmethod def link_object_data(cls, source_obj: bpy.types.Object, destination_obj: bpy.types.Object) -> None: destination_obj.data = source_obj.data diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py index 4f51e85ca6..0249b6ece6 100644 --- a/src/bonsai/bonsai/tool/snap.py +++ b/src/bonsai/bonsai/tool/snap.py @@ -27,15 +27,12 @@ from lark import Lark, Transformer class Snap(bonsai.core.tool.Snap): - mouse_pos = None - snap_angle = None - use_default_container = False + tool_state = None snap_plane_method = None - snap_axis_method = None @classmethod - def set_use_default_container(cls, value=True): - cls.use_default_container = value + def set_tool_state(cls, tool_state): + cls.tool_state = tool_state @classmethod def set_snap_plane_method(cls, value=True): @@ -48,13 +45,6 @@ class Snap(bonsai.core.tool.Snap): return cls.snap_plane_method = value - @classmethod - def set_snap_axis_method(cls, value=True): - if cls.snap_axis_method == value: - cls.snap_axis_method = None - return - cls.snap_axis_method = value - @classmethod def get_snap_points_on_raycasted_face(cls, context, event, obj, face_index): matrix = obj.matrix_world.copy() @@ -70,6 +60,7 @@ class Snap(bonsai.core.tool.Snap): return snap_point + # TODO Remove this function @classmethod def select_snap_point(cls, snap_points, hit, threshold): shortest_distance = None @@ -98,16 +89,15 @@ class Snap(bonsai.core.tool.Snap): except: snap_vertex = bpy.context.scene.BIMModelProperties.snap_mouse_point.add() - info = f"""Snap: {snap_type} - Axis:{cls.snap_axis_method} - Plane:{cls.snap_plane_method} -""" - PolylineDecorator.set_snap_info(info) snap_vertex.x = snap_point[0] snap_vertex.y = snap_point[1] snap_vertex.z = snap_point[2] snap_vertex.snap_type = snap_type + @classmethod + def clear_snapping_point(cls): + bpy.context.scene.BIMModelProperties.snap_mouse_point.clear() + @classmethod def update_snapping_ref(cls, snap_point, snap_type): try: @@ -125,18 +115,18 @@ class Snap(bonsai.core.tool.Snap): bpy.context.scene.BIMModelProperties.snap_mouse_ref.clear() @classmethod - def insert_polyline_point(cls, input_panel): - x = float(input_panel["X"]) - y = float(input_panel["Y"]) - try: - z = float(input_panel["Z"]) - except: - z = Vector((0, 0, 0)) - d = input_panel["D"] - a = input_panel["A"] + def insert_polyline_point(cls, input_ui): + x = input_ui.get_number_value("X") + y = input_ui.get_number_value("Y") + if input_ui.get_number_value("Z") is not None: + z = input_ui.get_number_value("Z") + else: + z = 0 + d = input_ui.get_formatted_value("D") + a = input_ui.get_formatted_value("A") snap_vertex = bpy.context.scene.BIMModelProperties.snap_mouse_point[0] - if cls.use_default_container: + if cls.tool_state.use_default_container: z = tool.Ifc.get_object(tool.Root.get_default_container()).location.z if x is None and y is None: @@ -186,12 +176,12 @@ class Snap(bonsai.core.tool.Snap): polyline_measurement.remove(len(polyline_measurement) - 1) @classmethod - def snap_on_axis(cls, intersection, lock_axis=None): + def snap_on_axis(cls, intersection, tool_state, lock_angle=False): def create_axis_line_data(rot_mat, origin): length = 1000 direction = Vector((1, 0, 0)) - if cls.snap_plane_method == "YZ" or (not cls.snap_plane_method and cls.snap_axis_method == "Z"): + if tool_state.plane_method == "YZ" or (not tool_state.plane_method and tool_state.axis_method == "Z"): direction = Vector((0, 0, 1)) rot_dir = rot_mat.inverted() @ direction start = origin + rot_dir * length @@ -202,13 +192,13 @@ class Snap(bonsai.core.tool.Snap): def create_axis_rectangle_data(origin): size = 0.5 direction = Vector((1, 0, 0)) - if cls.snap_plane_method == "YZ": + if tool_state.plane_method == "YZ": direction = Vector((0, 0, 1)) rot_mat = Matrix.Rotation(math.radians(360), 3, pivot_axis) rot_dir = rot_mat.inverted() @ direction v1 = origin + rot_dir * 0 v2 = origin + rot_dir * size - if cls.snap_plane_method == "XY": + if tool_state.plane_method == "XY": angle = 270 else: angle = 90 @@ -230,17 +220,17 @@ class Snap(bonsai.core.tool.Snap): # Translates intersection point based on last_point translated_intersection = intersection - last_point snap_axis = [] - if not lock_axis: - for i in range(1, 13): - angle = 30 * i + if not tool_state.snap_angle: + for i in range(1, 25): + angle = 15 * i snap_axis.append(angle) else: - snap_axis = [lock_axis] + snap_axis = [tool_state.snap_angle] pivot_axis = "Z" - if cls.snap_plane_method == "XZ": + if tool_state.plane_method == "XZ": pivot_axis = "Y" - if cls.snap_plane_method == "YZ": + if tool_state.plane_method == "YZ": pivot_axis = "X" for axis in snap_axis: @@ -248,10 +238,10 @@ class Snap(bonsai.core.tool.Snap): start, end = create_axis_line_data(rot_mat, last_point) rot_intersection = rot_mat @ translated_intersection proximity = rot_intersection.y - if cls.snap_plane_method == "XZ": + if tool_state.plane_method == "XZ": proximity = rot_intersection.z PolylineDecorator.set_angle_axis_line(start, end) - if lock_axis: + if lock_angle: is_on_rot_axis = True else: is_on_rot_axis = abs(proximity) <= 0.15 @@ -259,7 +249,7 @@ class Snap(bonsai.core.tool.Snap): if is_on_rot_axis: # Snap to axis rot_intersection = Vector((rot_intersection.x, 0, rot_intersection.z)) - if cls.snap_plane_method == "XZ": + if tool_state.plane_method == "XZ": rot_intersection = Vector((rot_intersection.x, rot_intersection.y, 0)) # Convert it back snap_intersection = rot_mat.inverted() @ rot_intersection + last_point @@ -280,11 +270,10 @@ class Snap(bonsai.core.tool.Snap): return sorted_intersections[0], "Mix" @classmethod - def detect_snapping_points(cls, context, event, objs_2d_bbox): - region = context.region + def detect_snapping_points(cls, context, event, objs_2d_bbox, tool_state): rv3d = context.region_data space = context.space_data - cls.mouse_pos = event.mouse_region_x, event.mouse_region_y + mouse_pos = event.mouse_region_x, event.mouse_region_y detected_snaps = [] snap_threshold = 0.3 @@ -306,14 +295,16 @@ class Snap(bonsai.core.tool.Snap): plane_origin = Vector((0, 0, 0)) plane_normal = Vector((0, 0, 1)) - if not cls.snap_plane_method: + if not tool_state.plane_method: camera_rotation = rv3d.view_rotation plane_origin = Vector((0, 0, 0)) view_direction = Vector((0, 0, -1)) @ camera_rotation.to_matrix().transposed() plane_normal = view_direction.normalized() - if cls.snap_plane_method == "XY" or (not cls.snap_plane_method and cls.snap_axis_method in {"X", "Y"}): - if cls.use_default_container: + if tool_state.plane_method == "XY" or ( + not tool_state.plane_method and tool_state.axis_method in {"X", "Y"} + ): + if cls.tool_state.use_default_container: plane_origin = Vector((0, 0, elevation)) elif not last_polyline_point: plane_origin = Vector((0, 0, 0)) @@ -321,19 +312,19 @@ class Snap(bonsai.core.tool.Snap): plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z)) plane_normal = Vector((0, 0, 1)) - elif cls.snap_plane_method == "XZ" or (not cls.snap_plane_method and cls.snap_axis_method == "Z"): + elif tool_state.plane_method == "XZ" or (not tool_state.plane_method and tool_state.axis_method == "Z"): if last_polyline_point: plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z)) plane_normal = Vector((0, 1, 0)) - elif cls.snap_plane_method == "YZ": + elif tool_state.plane_method == "YZ": if last_polyline_point: plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z)) plane_normal = Vector((1, 0, 0)) return plane_origin, plane_normal - def cast_rays_and_get_best_object(objs_to_raycast): + def cast_rays_and_get_best_object(objs_to_raycast, mouse_pos): best_length_squared = 1.0 best_obj = None best_hit = None @@ -343,13 +334,13 @@ class Snap(bonsai.core.tool.Snap): hit, normal, face_index = tool.Raycast.obj_ray_cast(context, event, obj) if hit is None: # Tried original mouse position. Now it will try the offsets. - original_mouse_pos = cls.mouse_pos + original_mouse_pos = mouse_pos for value in mouse_offset: - cls.mouse_pos = tuple(x + y for x, y in zip(original_mouse_pos, value)) - hit, normal, face_index = tool.Raycast.obj_ray_cast(context, event, obj, cls.mouse_pos) + mouse_pos = tuple(x + y for x, y in zip(original_mouse_pos, value)) + hit, normal, face_index = tool.Raycast.obj_ray_cast(context, event, obj, mouse_pos) if hit: break - cls.mouse_pos = original_mouse_pos + mouse_pos = original_mouse_pos if hit is not None: hit_world = obj.original.matrix_world @ hit @@ -371,14 +362,14 @@ class Snap(bonsai.core.tool.Snap): objs_to_raycast = [] for obj, bbox_2d in objs_2d_bbox: if obj.type == "MESH" and bbox_2d: - if tool.Raycast.intersect_mouse_2d_bounding_box(cls.mouse_pos, bbox_2d, offset): + if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d, offset): if space.local_view: if obj.local_view_get(context.space_data): objs_to_raycast.append(obj) else: objs_to_raycast.append(obj) # Obj - snap_obj, hit, face_index = cast_rays_and_get_best_object(objs_to_raycast) + snap_obj, hit, face_index = cast_rays_and_get_best_object(objs_to_raycast, mouse_pos) if hit is not None: detected_snaps.append({"Object": (snap_obj, hit, face_index)}) @@ -405,7 +396,6 @@ class Snap(bonsai.core.tool.Snap): elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z plane_origin, plane_normal = select_plane_method() - PolylineDecorator.set_plane(plane_origin, plane_normal) intersection = tool.Raycast.ray_cast_to_plane(context, event, plane_origin, plane_normal) axis_start = None @@ -413,30 +403,33 @@ class Snap(bonsai.core.tool.Snap): # TODO It only work for XY plane. Make it work also for None plane_method rot_intersection = None - if not cls.snap_plane_method: - if cls.snap_axis_method == "X": - cls.snap_angle = 180 - if cls.snap_axis_method == "Y": - cls.snap_angle = 90 - if cls.snap_axis_method == "Z": - cls.snap_angle = 90 - if cls.snap_axis_method: - rot_intersection, _, axis_start, axis_end = cls.snap_on_axis(intersection, cls.snap_angle) - - if cls.snap_plane_method: - if cls.snap_plane_method in {"XY", "XZ"} and cls.snap_axis_method == "X": - cls.snap_angle = 180 - if cls.snap_plane_method in {"XY", "YZ"} and cls.snap_axis_method == "Y": - cls.snap_angle = 90 - if cls.snap_plane_method in {"YZ"} and cls.snap_axis_method == "Z": - cls.snap_angle = 180 - if cls.snap_plane_method in {"XZ"} and cls.snap_axis_method == "Z": - cls.snap_angle = 90 - if event.shift or cls.snap_axis_method: + if not tool_state.plane_method: + if tool_state.axis_method == "X": + tool_state.snap_angle = 180 + if tool_state.axis_method == "Y": + tool_state.snap_angle = 90 + if tool_state.axis_method == "Z": + tool_state.snap_angle = 90 + if tool_state.axis_method: # Doesn't update snap_angle so that it keeps in the same axis - rot_intersection, _, axis_start, axis_end = cls.snap_on_axis(intersection, cls.snap_angle) + rot_intersection, _, axis_start, axis_end = cls.snap_on_axis(intersection, tool_state, True) + + if tool_state.plane_method: + if tool_state.plane_method in {"XY", "XZ"} and tool_state.axis_method == "X": + tool_state.snap_angle = 180 + if tool_state.plane_method in {"XY", "YZ"} and tool_state.axis_method == "Y": + tool_state.snap_angle = 90 + if tool_state.plane_method in {"YZ"} and tool_state.axis_method == "Z": + tool_state.snap_angle = 180 + if tool_state.plane_method in {"XZ"} and tool_state.axis_method == "Z": + tool_state.snap_angle = 90 + if event.shift or tool_state.axis_method: + # Doesn't update snap_angle so that it keeps in the same axis + rot_intersection, _, axis_start, axis_end = cls.snap_on_axis(intersection, tool_state, True) else: - rot_intersection, cls.snap_angle, axis_start, axis_end = cls.snap_on_axis(intersection, None) + rot_intersection, tool_state.snap_angle, axis_start, axis_end = cls.snap_on_axis( + intersection, tool_state, False + ) if rot_intersection: detected_snaps.append({"Axis": (rot_intersection, axis_start, axis_end)}) @@ -445,7 +438,7 @@ class Snap(bonsai.core.tool.Snap): return detected_snaps @classmethod - def select_snapping_points(cls, context, event, detected_snaps): + def select_snapping_points(cls, context, event, tool_state, detected_snaps): snapping_points = [] for origin in detected_snaps: if "Object" in list(origin.keys()): @@ -477,10 +470,6 @@ class Snap(bonsai.core.tool.Snap): snapping_points.append(op) break - if "Plane" in list(origin.keys()): - intersection = origin["Plane"] - snapping_points.append((intersection, "Plane")) - for origin in detected_snaps: if "Axis" in list(origin.keys()): intersection = origin["Axis"] @@ -488,8 +477,12 @@ class Snap(bonsai.core.tool.Snap): axis_end = intersection[2] snapping_points.append((intersection[0], "Axis")) + if "Plane" in list(origin.keys()): + intersection = origin["Plane"] + snapping_points.append((intersection, "Plane")) + # Make Axis first priority - if event.shift or cls.snap_axis_method in {"X", "Y", "Z"}: + if event.shift or tool_state.axis_method in {"X", "Y", "Z"}: cls.update_snapping_ref(snapping_points[0][0], snapping_points[0][1]) for point in snapping_points: if point[1] == "Axis": @@ -508,131 +501,3 @@ class Snap(bonsai.core.tool.Snap): shifted_list = snapping_points[1:] + snapping_points[:1] cls.update_snapping_point(shifted_list[0][0], shifted_list[0][1]) return shifted_list - - @classmethod - def validate_input(cls, input_number, input_type): - - grammar_imperial = """ - start: (FORMULA dim expr) | dim - dim: imperial - - FORMULA: "=" - - imperial: feet? "-"? inches? - feet: NUMBER? "-"? fraction? "'" - inches: NUMBER? "-"? fraction? "\\"" - fraction: NUMBER "/" NUMBER - - expr: (ADD | SUB) dim | (MUL | DIV) NUMBER - - NUMBER: /-?\\d+(?:\\.\\d+)?/ - ADD: "+" - SUB: "-" - MUL: "*" - DIV: "/" - - %ignore " " - """ - - grammar_metric = """ - start: FORMULA? dim expr? - dim: metric - - FORMULA: "=" - - metric: NUMBER - - expr: (ADD | SUB | MUL | DIV) dim - - NUMBER: /-?\\d+(?:\\.\\d+)?/ - ADD: "+" - SUB: "-" - MUL: "*" - DIV: "/" - - %ignore " " - """ - - class InputTransform(Transformer): - def NUMBER(self, n): - return float(n) - - def fraction(self, numbers): - return numbers[0] / numbers[1] - - def inches(self, args): - if len(args) > 1: - result = args[0] + args[1] - else: - result = args[0] - return result / 12 - - def feet(self, args): - return args[0] - - def imperial(self, args): - if len(args) > 1: - if args[0] <= 0: - result = args[0] - args[1] - else: - result = args[0] + args[1] - else: - result = args[0] - return result - - def metric(self, args): - return args[0] - - def dim(self, args): - return args[0] - - def expr(self, args): - op = args[0] - value = float(args[1]) - if op == "+": - return lambda x: x + value - elif op == "-": - return lambda x: x - value - elif op == "*": - return lambda x: x * value - elif op == "/": - return lambda x: x / value - - def FORMULA(cls, args): - return args[0] - - def start(self, args): - i = 0 - if args[0] == "=": - i += 1 - else: - if len(args) > 1: - raise ValueError("Invalid input.") - dimension = args[i] - if len(args) > i + 1: - expression = args[i + 1] - return expression(dimension) * factor - else: - return dimension * factor - - try: - if bpy.context.scene.unit_settings.system == "IMPERIAL": - parser = Lark(grammar_imperial) - factor = 0.3048 - else: - parser = Lark(grammar_metric) - factor = 1 - if bpy.context.scene.unit_settings.length_unit == "MILLIMETERS": - factor = 0.001 - - if input_type == "A": - parser = Lark(grammar_metric) - factor = 1 - - parse_tree = parser.parse(input_number) - - transformer = InputTransform() - result = transformer.transform(parse_tree) - return True, str(result) - except: - return False, "0" diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index e4147c61bc..d9442bd356 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -48,8 +48,7 @@ from natsort import natsorted class Spatial(bonsai.core.tool.Spatial): @classmethod def can_contain(cls, container: ifcopenshell.entity_instance, element_obj: Union[bpy.types.Object, None]) -> bool: - element = tool.Ifc.get_entity(element_obj) - if not element: + if not (element := tool.Ifc.get_entity(element_obj)): return False if tool.Ifc.get_schema() == "IFC2X3": if not container.is_a("IfcSpatialStructureElement"): diff --git a/src/bonsai/bonsai/tool/web.py b/src/bonsai/bonsai/tool/web.py index 5408efd898..d459ac1e3c 100644 --- a/src/bonsai/bonsai/tool/web.py +++ b/src/bonsai/bonsai/tool/web.py @@ -344,52 +344,6 @@ class Web(bonsai.core.tool.Web): operator_data (dict): A dictionary containing the operator data. """ - def selection_data(cost_item, elements): - print(cost_item, elements) - if not elements: - return [] - if not cost_item: - return [ - { - "info": { - "id": element.id(), - "name": element.Name, - "class": element.is_a(), - "type": get_type(element).Name if get_type(element) else None, - }, - "qtos": get_psets(element, qtos_only=True), - } - for element in elements or [] - ] - data = [] - for element in elements or []: - psets = get_psets(element, qtos_only=True) - element_type = get_type(element) - quantities, unit = tool.Cost.get_assigned_quantities(cost_item, element) - data.append( - { - "info": { - "id": element.id(), - "name": element.Name, - "class": element.is_a(), - "type": element_type.Name if element_type else None, - }, - "qtos": psets, - "assigned_quantities": [ - { - "cost_item_id": cost_item.id(), - "product_id": element.id(), - "name": q.Name, - "value": q[3], - "type": q.Unit, - } - for q in quantities or [] - ], - "unit": unit, - } - ) - return data - ifc_file = tool.Ifc.get() if operator_data["type"] == "getPredefinedTypes": print("getting predefined types") @@ -425,31 +379,6 @@ class Web(bonsai.core.tool.Web): cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) products = tool.Cost.get_cost_item_products(cost_item, is_deep=True) tool.Spatial.select_products(products, unhide=True) - if operator_data["type"] == "getSelectedProducts": - cost_item_id = operator_data["costItemId"] - cost_item = ifc_file.by_id(cost_item_id) - if not cost_item: - print("---> cost item not found") - return - - selected_products = list(tool.Spatial.get_selected_products()) - selected_products_data = selection_data(cost_item, selected_products) - - assigned_products = tool.Cost.get_cost_item_products(cost_item, is_deep=False) - assigned_products_data = selection_data(cost_item, assigned_products) - - names = ifcopenshell.util.cost.get_product_quantity_names(selected_products) - cls.send_webui_data( - data={ - "selected_products": selected_products_data, - "assigned_products": assigned_products_data, - "product_quantity_names": names, - "cost_item_id": cost_item_id, - }, - data_key="selected_products", - event="selected_products", - ) - if operator_data["type"] == "addSummaryCostItem": cost_schedule = ifc_file.by_id(operator_data["costScheduleId"]) bonsai.core.cost.add_summary_cost_item(tool.Ifc, tool.Cost, cost_schedule=cost_schedule) @@ -520,18 +449,120 @@ class Web(bonsai.core.tool.Web): prop_name = operator_data["propName"] if prop_name == "count": prop_name = "" - print(prop_name) bpy.ops.bim.assign_cost_item_quantity( cost_item=operator_data["costItemId"], related_object_type="PRODUCT", prop_name=prop_name ) cost_schedule = tool.Cost.get_cost_schedule(cost_item=tool.Ifc.get().by_id(operator_data["costItemId"])) cls.load_cost_schedule_web_ui(cost_schedule) + if operator_data["type"] == "enableEditingQuantities": + cost_item_id = operator_data["costItemId"] + cost_item = ifc_file.by_id(cost_item_id) + cls.enableEditingCostItemQuantities(cost_item) + if operator_data["type"] == "AddCostItemQuantity": + cost_item_id = operator_data["costItemId"] + cost_item = ifc_file.by_id(cost_item_id) + cost_schedule = tool.Cost.get_cost_schedule(cost_item) + bpy.ops.bim.add_cost_item_quantity(cost_item=cost_item_id, ifc_class=operator_data["ifcClass"]) + cls.load_cost_schedule_web_ui(cost_schedule) + cls.enableEditingCostItemQuantities(cost_item) + if operator_data["type"] == "editCostItemQuantity": + cost_item_id = operator_data["costItemId"] + cost_item = ifc_file.by_id(cost_item_id) + cost_schedule = tool.Cost.get_cost_schedule(cost_item) + physical_quantity = tool.Ifc.get().by_id(operator_data["quantityId"]) + attributes = operator_data["attributes"] + tool.Ifc.run("cost.edit_cost_item_quantity", physical_quantity=physical_quantity, attributes=attributes) + tool.Cost.load_cost_item_quantities(ifc_file.by_id(operator_data["costItemId"])) + cls.load_cost_schedule_web_ui(cost_schedule) + cls.enableEditingCostItemQuantities(cost_item) + if operator_data["type"] == "deleteCostItemQuantity": + bpy.ops.bim.remove_cost_item_quantity( + cost_item=operator_data["costItemId"], physical_quantity=operator_data["quantityId"] + ) + cost_item_id = operator_data["costItemId"] + cost_item = ifc_file.by_id(cost_item_id) + cost_schedule = tool.Cost.get_cost_schedule(cost_item) + cls.load_cost_schedule_web_ui(cost_schedule) + cls.enableEditingCostItemQuantities(cost_item) + + @classmethod + def enableEditingCostItemQuantities(cls, cost_item): + if not cost_item: + return + selected_products = list(tool.Spatial.get_selected_products()) + selected_products_data = cls.selection_data(cost_item, selected_products) + + assigned_products = tool.Cost.get_cost_item_products(cost_item, is_deep=False) + assigned_products_data = cls.selection_data(cost_item, assigned_products) + + names = ifcopenshell.util.cost.get_product_quantity_names(selected_products) + cls.send_webui_data( + data={ + "selected_products": selected_products_data, + "assigned_products": assigned_products_data, + "product_quantity_names": names, + "cost_item_id": cost_item.id(), + "cost_quantities": tool.Cost.get_cost_quantities(cost_item), + }, + data_key="quantities", + event="quantities", + ) + + @classmethod + def selection_data(cls, cost_item, elements): + if not elements: + return [] + if not cost_item: + return [ + { + "info": { + "id": element.id(), + "name": element.Name, + "class": element.is_a(), + "type": get_type(element).Name if get_type(element) else None, + }, + "qtos": get_psets(element, qtos_only=True), + } + for element in elements or [] + ] + data = [] + for element in elements or []: + psets = get_psets(element, qtos_only=True) + element_type = get_type(element) + quantities, unit = tool.Cost.get_assigned_quantities(cost_item, element) + data.append( + { + "info": { + "id": element.id(), + "name": element.Name, + "class": element.is_a(), + "type": element_type.Name if element_type else None, + }, + "qtos": psets, + "assigned_quantities": [ + { + "cost_item_id": cost_item.id(), + "product_id": element.id(), + "name": q.Name, + "value": q[3], + "type": q.Unit, + } + for q in quantities or [] + ], + "unit": unit, + } + ) + return data @classmethod def load_cost_schedule_web_ui(cls, cost_schedule): json_data = tool.Cost.create_cost_schedule_json(cost_schedule) cls.send_webui_data( - data={"cost_items": json_data, "cost_schedule_id": cost_schedule.id()}, + data={ + "cost_items": json_data, + "cost_schedule_id": cost_schedule.id(), + "currency": tool.Cost.currency(), + }, data_key="cost_items", event="cost_items", ) diff --git a/src/ifc5d/ifc5d/qto.py b/src/ifc5d/ifc5d/qto.py index f73d5870c1..6673997ce2 100644 --- a/src/ifc5d/ifc5d/qto.py +++ b/src/ifc5d/ifc5d/qto.py @@ -24,22 +24,29 @@ import ifcopenshell.api.pset import ifcopenshell.util.unit import ifcopenshell.util.element import ifcopenshell.util.selector +import ifcopenshell.util.shape import ifcopenshell.util.representation import multiprocessing from collections import namedtuple -from typing import Any +from typing import Any, Literal, get_args Function = namedtuple("Function", ["measure", "name", "description"]) -rules = {} +RULE_SET = Literal["IFC4QtoBaseQuantities", "IFC4QtoBaseQuantitiesBlender"] +rules: dict[RULE_SET, dict[str, Any]] = {} cwd = os.path.dirname(os.path.realpath(__file__)) -for name in ("IFC4QtoBaseQuantities", "IFC4QtoBaseQuantitiesBlender"): +for name in get_args(RULE_SET): with open(os.path.join(cwd, name + ".json"), "r") as f: rules[name] = json.load(f) def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_instance], rules: dict) -> dict: + """ + + :param rules: Set of rules from `ifc5d.qto.rules`. + + """ results = {} for calculator, queries in rules["calculators"].items(): calculator = calculators[calculator] @@ -51,6 +58,11 @@ def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_inst def edit_qtos(ifc_file: ifcopenshell.file, results: dict[ifcopenshell.entity_instance, Any]) -> None: + """ + + :param results: Results from `ifc5d.qto.quantify`. + + """ for element, qtos in results.items(): for name, quantities in qtos.items(): qto = ifcopenshell.util.element.get_pset(element, name, should_inherit=False) diff --git a/src/ifcgeom/AbstractKernel.cpp b/src/ifcgeom/AbstractKernel.cpp index 23d7bc1e21..f785114420 100644 --- a/src/ifcgeom/AbstractKernel.cpp +++ b/src/ifcgeom/AbstractKernel.cpp @@ -3,6 +3,7 @@ #include "../ifcgeom/IfcGeomElement.h" #include "../ifcgeom/ConversionSettings.h" #include "../ifcgeom/abstract_mapping.h" +#include "../ifcgeom/piecewise_function_evaluator.h" #ifdef IFOPSH_WITH_OPENCASCADE #include "../ifcgeom/kernels/opencascade/OpenCascadeKernel.h" @@ -227,7 +228,8 @@ bool ifcopenshell::geometry::kernels::AbstractKernel::convert_impl(const taxonom } bool ifcopenshell::geometry::kernels::AbstractKernel::convert_impl(const taxonomy::piecewise_function::ptr item, IfcGeom::ConversionResults& cs) { - auto expl = item->evaluate(); + piecewise_function_evaluator evaluator(item); + auto expl = evaluator.evaluate(); expl->instance = item->instance; return convert(expl, cs); } diff --git a/src/ifcgeom/Iterator.h b/src/ifcgeom/Iterator.h index 8bf862def1..242cdb7418 100644 --- a/src/ifcgeom/Iterator.h +++ b/src/ifcgeom/Iterator.h @@ -701,6 +701,10 @@ namespace IfcGeom { /// Gets the representation of the current geometrical entity. Element* get() { + if (!initialization_outcome_) { + throw std::runtime_error("Iterator not initialized"); + } + auto ret = *task_result_iterator_; // If we want to organize the element considering their hierarchy diff --git a/src/ifcgeom/infra_sweep_helper.cpp b/src/ifcgeom/infra_sweep_helper.cpp new file mode 100644 index 0000000000..5456a14fc9 --- /dev/null +++ b/src/ifcgeom/infra_sweep_helper.cpp @@ -0,0 +1,203 @@ +#include "profile_helper.h" +#include "infra_sweep_helper.h" +#include "piecewise_function_evaluator.h" + +#include + +using namespace ifcopenshell::geometry; + +namespace { + // std::lerp when upgrading to C++ 20 + template + T lerp(const T& a, const T& b, double t) { + return a + t * (b - a); + } +} + +taxonomy::loft::ptr ifcopenshell::geometry::make_loft(const Settings& settings_, const IfcUtil::IfcBaseClass* inst, const taxonomy::piecewise_function::ptr& pwf, std::vector& cross_sections) +{ + std::sort(cross_sections.begin(), cross_sections.end()); + + auto loft = taxonomy::make(); + // @todo intialize as default + loft->axis = nullptr; + + // @todo currently only the case is handled where directrix returns a piecewise_function + // @todo this "if" statement is not really required because the function returns at the start if the Directrix is not a piecewise function + if (pwf) { + piecewise_function_evaluator evaluator(pwf, &settings_); + double start = std::max(0., cross_sections.front().dist_along); + double end = std::min(pwf->length(), cross_sections.back().dist_along); + + if (end - start < 1.e-9) { + Logger::Warning("Empty sweep domain with start at " + std::to_string(cross_sections.front().dist_along) + " end at " + std::to_string(cross_sections.back().dist_along) + " and curve domain length " + std::to_string(pwf->length()), inst); + return nullptr; + } + + auto curve_length = end - start; + auto param_type = settings_.get().get(); + auto param = settings_.get().get(); + size_t num_steps = 0; + if (param_type == ifcopenshell::geometry::settings::PiecewiseStepMethod::MAXSTEPSIZE) { + // parameter is max step size + num_steps = (size_t)std::ceil(curve_length / param); + } else { + // parameter is minimum number of steps + num_steps = (size_t)std::ceil(param); + } + std::vector longitudes; + for (auto& x : cross_sections) { + longitudes.push_back(x.dist_along); + } + longitudes.push_back(std::numeric_limits::infinity()); + auto profile_index = longitudes.begin(); + for (size_t i = 0; i <= num_steps; ++i) { + auto dist_along = start + curve_length / num_steps * i; + while (dist_along > *(profile_index + 1)) { + profile_index++; + if (profile_index == longitudes.end()) { + // @todo handle this? + } + } + + auto relative_dist_along = (dist_along - *profile_index) / (*(profile_index + 1) - *profile_index); + const auto& profile_a = cross_sections[std::distance(longitudes.begin(), profile_index)].section_geometry; + const auto& offset_a = cross_sections[std::distance(longitudes.begin(), profile_index)].offset; + + taxonomy::geom_item::ptr interpolated = nullptr; + + // Only interpolate if: + // - there is a profile ahead of us, and + // - we're not exactly at the location of the current profile or whether there is an offset involved. + bool should_interpolate = + (profile_index + 1 < longitudes.end()) && + (relative_dist_along >= 1.e-9 || offset_a.cwiseAbs().maxCoeff() > 0.); + + if (should_interpolate) { + taxonomy::geom_item::ptr profile_b; + Eigen::Vector3d offset_b; + if ((profile_index + 1 < longitudes.end())) { + profile_b = cross_sections[std::distance(longitudes.begin(), profile_index) + 1].section_geometry; + offset_b = cross_sections[std::distance(longitudes.begin(), profile_index) + 1].offset; + } else { + profile_b = profile_a; + offset_b = offset_a; + } + + // Only interpolate if the profiles are different or either of the offsets is non-zero + bool should_interpolate2 = + (profile_a->instance != profile_b->instance) || + (offset_a.cwiseAbs().maxCoeff() > 0. || offset_b.cwiseAbs().maxCoeff() > 0.); + + if (should_interpolate2) { + + std::vector loops_a, loops_b; + + if (profile_a->kind() == taxonomy::FACE) { + interpolated = taxonomy::make(); + + auto profile_a_f = std::static_pointer_cast(profile_a); + auto profile_b_f = std::static_pointer_cast(profile_b); + + if (profile_a_f->children.size() != profile_b_f->children.size()) { + Logger::Warning("Mismatching number of face boundaries: " + + std::to_string(profile_a_f->children.size()) + " vs " + + std::to_string(profile_b_f->children.size()), + inst + ); + return nullptr; + } + loops_a = profile_a_f->children; + loops_b = profile_b_f->children; + } else { + loops_a = { std::static_pointer_cast(profile_a) }; + loops_b = { std::static_pointer_cast(profile_b) }; + interpolated = taxonomy::make(); + } + + // @todo should_interpolate should also be informed based by different face matrices. + if (profile_a->matrix || profile_b->matrix) { + interpolated->matrix = taxonomy::make(); + Eigen::Matrix4d m4a = Eigen::Matrix4d::Identity(); + Eigen::Matrix4d m4b = Eigen::Matrix4d::Identity(); + if (profile_a->matrix) { + m4a = profile_a->matrix->ccomponents(); + } + if (profile_b->matrix) { + m4b = profile_b->matrix->ccomponents(); + } + interpolated->matrix->components() = lerp(m4a, m4b, relative_dist_along); + } + + auto interpolated_offset = lerp(offset_a, offset_b, relative_dist_along); + taxonomy::loop::ptr w1, w2; + taxonomy::edge::ptr e1, e2; + for (auto tmp_ : boost::combine(loops_a, loops_b)) { + boost::tie(w1, w2) = tmp_; + if (w1->children.size() != w2->children.size()) { + Logger::Warning("Mismatching number of edges: " + + std::to_string(w1->children.size()) + " vs " + + std::to_string(w2->children.size()), + inst + ); + return nullptr; + } + std::vector points; + for (auto tmp__ : boost::combine(w1->children, w2->children)) { + boost::tie(e1, e2) = tmp__; + auto& p1 = boost::get(e1->start); + auto& p2 = boost::get(e2->start); + + auto p3 = (lerp(p1->ccomponents(), p2->ccomponents(), relative_dist_along) + interpolated_offset).eval(); + points.push_back(taxonomy::make(p3)); + } + if (!points.empty()) { + // close polygon by referencing first point + // @todo add a closed=true|false to polygon_from_points()? + points.push_back(points.front()); + } + + auto interpolated_loop = polygon_from_points(points); + if (interpolated->kind() == taxonomy::FACE) { + std::static_pointer_cast(interpolated)->children.push_back(interpolated_loop); + } else { + std::static_pointer_cast(interpolated)->children = interpolated_loop->children; + } + } + } + } + + auto m4 = evaluator.evaluate(dist_along); + /* { + std::wcout << "#" << pwf->instance->data().id() << " " << dist_along << ": " << m4.col(3).row(2).value() << std::endl; + }*/ + + Eigen::Matrix4d m4b = Eigen::Matrix4d::Identity(); + m4b.col(0).head<3>() = m4.col(1).head<3>().normalized(); + m4b.col(1).head<3>() = m4.col(2).head<3>().normalized(); + m4b.col(2).head<3>() = m4.col(0).head<3>().normalized(); + m4b.col(3).head<3>() = m4.col(3).head<3>(); + + if (interpolated) { + loft->children.push_back(interpolated); + } else { + if (profile_a->kind() == taxonomy::FACE) { + loft->children.push_back(std::static_pointer_cast(taxonomy::item::ptr(profile_a->clone_()))); + } else { + loft->children.push_back(std::static_pointer_cast(taxonomy::item::ptr(profile_a->clone_()))); + } + if (profile_a->matrix) { + loft->children.back()->matrix = taxonomy::matrix4::ptr(profile_a->matrix->clone_()); + } + } + if (!loft->children.back()->matrix) { + // @todo should this not be initialized by default? matrix4 already has a 'lazy identity' mechanism. + loft->children.back()->matrix = taxonomy::make(); + } + auto m = (m4b * loft->children.back()->matrix->ccomponents()).eval(); + loft->children.back()->matrix->components() = m; + } + } + + return loft; +} diff --git a/src/ifcgeom/infra_sweep_helper.h b/src/ifcgeom/infra_sweep_helper.h new file mode 100644 index 0000000000..41b38e6c9a --- /dev/null +++ b/src/ifcgeom/infra_sweep_helper.h @@ -0,0 +1,26 @@ +#ifndef LINEAR_SWEEP_HELPER_H +#define LINEAR_SWEEP_HELPER_H + +#include "taxonomy.h" +#include "ConversionSettings.h" + +namespace ifcopenshell { + + namespace geometry { + + struct cross_section { + double dist_along; + taxonomy::geom_item::ptr section_geometry; + Eigen::Vector3d offset; + + bool operator <(const cross_section& other) const { + return dist_along < other.dist_along; + } + }; + + taxonomy::loft::ptr make_loft(const Settings& settings_, const IfcUtil::IfcBaseClass* inst, const taxonomy::piecewise_function::ptr& directrix, std::vector& cross_sections); + } + +} + +#endif \ No newline at end of file diff --git a/src/ifcgeom/kernels/opencascade/loft.cpp b/src/ifcgeom/kernels/opencascade/loft.cpp index b418675fbd..70e174fe01 100644 --- a/src/ifcgeom/kernels/opencascade/loft.cpp +++ b/src/ifcgeom/kernels/opencascade/loft.cpp @@ -48,26 +48,43 @@ bool OpenCascadeKernel::convert(const taxonomy::loft::ptr loft, TopoDS_Shape& re for (auto it = loft->children.begin(); it < loft->children.end() - 1; ++it) { auto jt = it + 1; - std::array fa = { *it, *jt }; + std::array fa = { *it, *jt }; std::array shps; std::array ws; for (int i = 0; i < 2; ++i) { - if (!convert(fa[i], shps[i])) { - return false; + if (fa[i]->kind() == taxonomy::FACE) { + if (!convert(std::static_pointer_cast(fa[i]), shps[i])) { + return false; + } } - if (shps[i].ShapeType() != TopAbs_FACE) { + if (fa[i]->kind() == taxonomy::LOOP) { + TopoDS_Wire w; + if (!convert(std::static_pointer_cast(fa[i]), w)) { + return false; + } + shps[i] = w; + } + if (shps[i].ShapeType() != TopAbs_FACE && shps[i].ShapeType() != TopAbs_WIRE) { return false; } // @todo this is only outer wire - ws[i] = BRepTools::OuterWire(TopoDS::Face(shps[i])); + if (shps[i].ShapeType() == TopAbs_FACE) { + ws[i] = BRepTools::OuterWire(TopoDS::Face(shps[i])); + } else { + ws[i] = TopoDS::Wire(shps[i]); + } } - if (it == loft->children.begin()) { - // faces.Append(shps[0]); - BB.Add(comp, shps[0]); - } - if (jt == loft->children.end() - 1) { - // faces.Append(shps[1]); - BB.Add(comp, shps[1]); + if (shps[0].ShapeType() == TopAbs_FACE) { + // When processing a sectioned *surface* there are no + // begin and end caps that need to be added. + if (it == loft->children.begin()) { + // faces.Append(shps[0]); + BB.Add(comp, shps[0]); + } + if (jt == loft->children.end() - 1) { + // faces.Append(shps[1]); + BB.Add(comp, shps[1]); + } } BRepTools_WireExplorer a(ws[0]); BRepTools_WireExplorer b(ws[1]); diff --git a/src/ifcgeom/mapping/IfcCompositeCurve.cpp b/src/ifcgeom/mapping/IfcCompositeCurve.cpp index b9f2aaaf4d..568b861ad7 100644 --- a/src/ifcgeom/mapping/IfcCompositeCurve.cpp +++ b/src/ifcgeom/mapping/IfcCompositeCurve.cpp @@ -92,7 +92,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcCompositeCurve* inst) { return loop; } else { - auto pwf = taxonomy::make(0.0,pwfs,&settings_,inst); + auto pwf = taxonomy::make(0.0,pwfs,inst); return pwf; } } diff --git a/src/ifcgeom/mapping/IfcCurveSegment.cpp b/src/ifcgeom/mapping/IfcCurveSegment.cpp index 343cb8300d..e2f38135e3 100644 --- a/src/ifcgeom/mapping/IfcCurveSegment.cpp +++ b/src/ifcgeom/mapping/IfcCurveSegment.cpp @@ -961,7 +961,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcCurveSegment* inst) { taxonomy::piecewise_function::spans_t spans; spans.emplace_back(fabs(length), fn); - auto pwf = taxonomy::make(0.0, spans,&settings_,inst); + auto pwf = taxonomy::make(0.0, spans,inst); return pwf; } diff --git a/src/ifcgeom/mapping/IfcEllipseProfileDef.cpp b/src/ifcgeom/mapping/IfcEllipseProfileDef.cpp index d07e3e80fb..5608b01cdf 100644 --- a/src/ifcgeom/mapping/IfcEllipseProfileDef.cpp +++ b/src/ifcgeom/mapping/IfcEllipseProfileDef.cpp @@ -39,6 +39,9 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcEllipseProfileDef* inst) { #endif if (has_position) { m4 = taxonomy::cast(map(inst->Position())); + } else { + // matrix needs to be set on elementary curves. + m4 = taxonomy::make(); } if (ry > rx) { @@ -58,9 +61,9 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcEllipseProfileDef* inst) { auto el = taxonomy::make(); el->radius = rx; el->radius2 = ry; + el->matrix = m4; ed->basis = el; lp->children.push_back(ed); fc->children.push_back(lp); - fc->matrix = m4; return fc; } diff --git a/src/ifcgeom/mapping/IfcFixedReferenceSweptAreaSolid.cpp b/src/ifcgeom/mapping/IfcFixedReferenceSweptAreaSolid.cpp index 7267f61b2a..79632b9f01 100644 --- a/src/ifcgeom/mapping/IfcFixedReferenceSweptAreaSolid.cpp +++ b/src/ifcgeom/mapping/IfcFixedReferenceSweptAreaSolid.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "mapping.h" +#include "../piecewise_function_evaluator.h" #define mapping POSTFIX_SCHEMA(mapping) using namespace ifcopenshell::geometry; @@ -34,6 +35,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcFixedReferenceSweptAreaSolid // @todo currently only the case is handled where directrix returns a piecewise_function if (auto pwf = taxonomy::dcast(dir)) { + piecewise_function_evaluator evaluator(pwf,&settings_); double start = 0; double end = pwf->length(); #ifdef SCHEMA_HAS_IfcDirectrixCurveSweptAreaSolid @@ -53,20 +55,9 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcFixedReferenceSweptAreaSolid } } #endif - auto curve_length = end - start; - auto param_type = settings_.get().get(); - auto param = settings_.get().get(); - size_t num_steps = 0; - if (param_type == ifcopenshell::geometry::settings::PiecewiseStepMethod::MAXSTEPSIZE) { - // parameter is max step size - num_steps = (size_t) std::ceil(curve_length / param); - } else { - // parameter is minimum number of steps - num_steps = (size_t) std::ceil(param); - } - for (size_t i = 0; i <= num_steps; ++i) { - auto distalong = start + curve_length / num_steps * i; - auto m4 = pwf->evaluate(distalong); + auto evaluation_points = evaluator.evaluation_points(); + for (const auto& dist_along : evaluation_points) { + auto m4 = evaluator.evaluate(dist_along); /* std::stringstream ss; diff --git a/src/ifcgeom/mapping/IfcGradientCurve.cpp b/src/ifcgeom/mapping/IfcGradientCurve.cpp index 3c5b95635a..6217b81a0e 100644 --- a/src/ifcgeom/mapping/IfcGradientCurve.cpp +++ b/src/ifcgeom/mapping/IfcGradientCurve.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "mapping.h" +#include "../piecewise_function_evaluator.h" #define mapping POSTFIX_SCHEMA(mapping) using namespace ifcopenshell::geometry; @@ -55,7 +56,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcGradientCurve* inst) { double gradient_start = m(0, 3); // start of vertical (row 0, col 3) - "Distance Along" horizontal curve // create the vertical pwf - auto vertical = taxonomy::make(gradient_start, pwfs, &settings_); + auto vertical = taxonomy::make(gradient_start, pwfs); // Determine the valid domain of the PWF... the valid domain is where both // the base curve and gradient curves are defined @@ -69,11 +70,12 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcGradientCurve* inst) { } // define the callback function for the gradient curve - auto composition = [horizontal, vertical](double u)->Eigen::Matrix4d { + piecewise_function_evaluator horizontal_evaluator(horizontal, &settings_), vertical_evaluator(vertical, &settings_); + auto composition = [horizontal_evaluator, vertical_evaluator,start=vertical->start()](double u) -> Eigen::Matrix4d { // u is distance from start of gradient curve (vertical) // add vertical->start() to u to get distance from start of horizontal - auto xy = horizontal->evaluate(u + vertical->start()); - auto uz = vertical->evaluate(u); + auto xy = horizontal_evaluator.evaluate(u + start); + auto uz = vertical_evaluator.evaluate(u); uz.col(3)(0) = 0.0; // x is distance along. zero it out so it doesn't add to the x from horizontal uz.col(1).swap(uz.col(2)); // uz is 2D in distance along - y plane, swap y and z so elevations become z @@ -86,7 +88,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcGradientCurve* inst) { taxonomy::piecewise_function::spans_t spans; spans.emplace_back(length, composition); - auto pwf = taxonomy::make(start, spans, &settings_, inst); + auto pwf = taxonomy::make(start, spans, inst); return pwf; } diff --git a/src/ifcgeom/mapping/IfcOffsetCurveByDistance.cpp b/src/ifcgeom/mapping/IfcOffsetCurveByDistance.cpp index 7af8b42bd8..1ed4d05e8b 100644 --- a/src/ifcgeom/mapping/IfcOffsetCurveByDistance.cpp +++ b/src/ifcgeom/mapping/IfcOffsetCurveByDistance.cpp @@ -19,6 +19,7 @@ #include "mapping.h" #include "../profile_helper.h" +#include "../piecewise_function_evaluator.h" #define mapping POSTFIX_SCHEMA(mapping) using namespace ifcopenshell::geometry; @@ -144,11 +145,12 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcOffsetCurveByDistances* inst offset_spans.emplace_back(l, fn); } - auto offsets = taxonomy::make(start,offset_spans,&settings_); + auto offsets = taxonomy::make(start,offset_spans); - auto composition = [pw_curve, offsets](double u) -> Eigen::Matrix4d { - auto p = pw_curve->evaluate(u); - auto offset = offsets->evaluate(u); + piecewise_function_evaluator pw_evaluator(pw_curve, &settings_), offsets_evaluator(offsets, &settings_); + auto composition = [pw_evaluator, offsets_evaluator](double u) -> Eigen::Matrix4d { + auto p = pw_evaluator.evaluate(u); + auto offset = offsets_evaluator.evaluate(u); Eigen::Matrix4d m = p * offset; return m; }; @@ -157,7 +159,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcOffsetCurveByDistances* inst // this may change depending on decisions in the bSI-IF taxonomy::piecewise_function::spans_t spans; spans.emplace_back(basis_curve_length, composition); - auto pwf = taxonomy::make(start,spans,&settings_,inst); + auto pwf = taxonomy::make(start,spans,inst); return pwf; } diff --git a/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp index 601765e1a3..6097344eb3 100644 --- a/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp +++ b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp @@ -19,6 +19,7 @@ #include "mapping.h" #include "../profile_helper.h" +#include "../piecewise_function_evaluator.h" #define mapping POSTFIX_SCHEMA(mapping) using namespace ifcopenshell::geometry; @@ -31,7 +32,8 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPointByDistanceExpression* i //auto item = map(basis_curve); //auto pw_curve = ifcopenshell::geometry::piecewise_from_item(item); auto pw_curve = taxonomy::dcast(map(inst->BasisCurve())); - auto m = pw_curve->evaluate(u); + piecewise_function_evaluator evaluator(pw_curve,&settings_); + auto m = evaluator.evaluate(u); auto o = m.col(3).head<3>(); auto z = m.col(2).head<3>(); diff --git a/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp b/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp index 74e6e4cd89..14ee9899ad 100644 --- a/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp +++ b/src/ifcgeom/mapping/IfcSectionedSolidHorizontal.cpp @@ -22,28 +22,10 @@ using namespace ifcopenshell::geometry; #include "../../ifcgeom/profile_helper.h" - -#include +#include "../../ifcgeom/infra_sweep_helper.h" #ifdef SCHEMA_HAS_IfcSectionedSolidHorizontal -namespace { - // std::lerp when upgrading to C++ 20 - template - T lerp(const T& a, const T& b, double t) { - return a + t * (b - a); - } - - struct cross_section { - double dist_along; - taxonomy::face::ptr section_geometry; - Eigen::Vector3d offset; - - bool operator <(const cross_section& other) const { - return dist_along < other.dist_along; - } - }; -} taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* inst) { std::vector cross_sections; @@ -105,162 +87,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSolidHorizontal* in } } - std::sort(cross_sections.begin(), cross_sections.end()); - - auto loft = taxonomy::make(); - // @todo intialize as default - loft->axis = nullptr; - - // @todo currently only the case is handled where directrix returns a piecewise_function - // @todo this "if" statement is not really required because the function returns at the start if the Directrix is not a piecewise function - if (pwf) { - double start = std::max(0., cross_sections.front().dist_along); - double end = std::min(pwf->length(), cross_sections.back().dist_along); - - if (end - start < 1.e-9) { - Logger::Warning("Empty sweep domain with start at " + std::to_string(cross_sections.front().dist_along) + " end at " + std::to_string(cross_sections.back().dist_along) + " and curve domain length " + std::to_string(pwf->length()), inst); - return nullptr; - } - - auto curve_length = end - start; - auto param_type = settings_.get().get(); - auto param = settings_.get().get(); - size_t num_steps = 0; - if (param_type == ifcopenshell::geometry::settings::PiecewiseStepMethod::MAXSTEPSIZE) { - // parameter is max step size - num_steps = (size_t) std::ceil(curve_length / param); - } else { - // parameter is minimum number of steps - num_steps = (size_t) std::ceil(param); - } - std::vector longitudes; - for (auto& x : cross_sections) { - longitudes.push_back(x.dist_along); - } - longitudes.push_back(std::numeric_limits::infinity()); - auto profile_index = longitudes.begin(); - for (size_t i = 0; i <= num_steps; ++i) { - auto dist_along = start + curve_length / num_steps * i; - while (dist_along > *(profile_index+1)) { - profile_index++; - if (profile_index == longitudes.end()) { - // @todo handle this? - } - } - - auto relative_dist_along = (dist_along - *profile_index) / (*(profile_index+1) - *profile_index); - const auto& profile_a = cross_sections[std::distance(longitudes.begin(), profile_index)].section_geometry; - const auto& offset_a = cross_sections[std::distance(longitudes.begin(), profile_index)].offset; - - taxonomy::face::ptr interpolated = nullptr; - - // Only interpolate if: - // - there is a profile ahead of us, and - // - we're not exactly at the location of the current profile or whether there is an offset involved. - bool should_interpolate = - (profile_index + 1 < longitudes.end()) && - (relative_dist_along >= 1.e-9 || offset_a.cwiseAbs().maxCoeff() > 0.); - - if (should_interpolate) { - taxonomy::face::ptr profile_b; - Eigen::Vector3d offset_b; - if ((profile_index + 1 < longitudes.end())) { - profile_b = cross_sections[std::distance(longitudes.begin(), profile_index) + 1].section_geometry; - offset_b = cross_sections[std::distance(longitudes.begin(), profile_index) + 1].offset; - } else { - profile_b = profile_a; - offset_b = offset_a; - } - - // Only interpolate if the profiles are different or either of the offsets is non-zero - bool should_interpolate2 = - (profile_a->instance != profile_b->instance) || - (offset_a.cwiseAbs().maxCoeff() > 0. || offset_b.cwiseAbs().maxCoeff() > 0.); - - if (should_interpolate2) { - if (profile_a->children.size() != profile_b->children.size()) { - Logger::Warning("Mismatching number of face boundaries: " + - std::to_string(profile_a->children.size()) + " vs " + - std::to_string(profile_b->children.size()), - inst - ); - return nullptr; - } - interpolated = taxonomy::make(); - // @todo should_interpolate should also be informed based by different face matrices. - if (profile_a->matrix || profile_b->matrix) { - interpolated->matrix = taxonomy::make(); - Eigen::Matrix4d m4a = Eigen::Matrix4d::Identity(); - Eigen::Matrix4d m4b = Eigen::Matrix4d::Identity(); - if (profile_a->matrix) { - m4a = profile_a->matrix->ccomponents(); - } - if (profile_b->matrix) { - m4b = profile_b->matrix->ccomponents(); - } - interpolated->matrix->components() = lerp(m4a, m4b, relative_dist_along); - } - auto interpolated_offset = lerp(offset_a, offset_b, relative_dist_along); - taxonomy::loop::ptr w1, w2; - taxonomy::edge::ptr e1, e2; - for (auto tmp_ : boost::combine(profile_a->children, profile_b->children)) { - boost::tie(w1, w2) = tmp_; - if (w1->children.size() != w2->children.size()) { - Logger::Warning("Mismatching number of edges for face boundary: " + - std::to_string(w1->children.size()) + " vs " + - std::to_string(w2->children.size()), - inst - ); - return nullptr; - } - std::vector points; - for (auto tmp__ : boost::combine(w1->children, w2->children)) { - boost::tie(e1, e2) = tmp__; - auto& p1 = boost::get(e1->start); - auto& p2 = boost::get(e2->start); - - auto p3 = (lerp(p1->ccomponents(), p2->ccomponents(), relative_dist_along) + interpolated_offset).eval(); - points.push_back(taxonomy::make(p3)); - } - if (!points.empty()) { - // close polygon by referencing first point - // @todo add a closed=true|false to polygon_from_points()? - points.push_back(points.front()); - } - interpolated->children.push_back(polygon_from_points(points)); - } - } - } - - auto m4 = pwf->evaluate(dist_along); - /* { - std::wcout << "#" << pwf->instance->data().id() << " " << dist_along << ": " << m4.col(3).row(2).value() << std::endl; - }*/ - - Eigen::Matrix4d m4b = Eigen::Matrix4d::Identity(); - m4b.col(0).head<3>() = m4.col(1).head<3>().normalized(); - m4b.col(1).head<3>() = m4.col(2).head<3>().normalized(); - m4b.col(2).head<3>() = m4.col(0).head<3>().normalized(); - m4b.col(3).head<3>() = m4.col(3).head<3>(); - - if (interpolated) { - loft->children.push_back(interpolated); - } else { - loft->children.push_back(taxonomy::face::ptr(profile_a->clone_())); - if (profile_a->matrix) { - loft->children.back()->matrix = taxonomy::matrix4::ptr(profile_a->matrix->clone_()); - } - } - if (!loft->children.back()->matrix) { - // @todo should this not be initialized by default? matrix4 already has a 'lazy identity' mechanism. - loft->children.back()->matrix = taxonomy::make(); - } - auto m = (m4b * loft->children.back()->matrix->ccomponents()).eval(); - loft->children.back()->matrix->components() = m; - } - } - - return loft; + return make_loft(settings_, inst, pwf, cross_sections); } #endif diff --git a/src/ifcgeom/mapping/IfcSectionedSurface.cpp b/src/ifcgeom/mapping/IfcSectionedSurface.cpp new file mode 100644 index 0000000000..3edcde7946 --- /dev/null +++ b/src/ifcgeom/mapping/IfcSectionedSurface.cpp @@ -0,0 +1,93 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#include "mapping.h" +#define mapping POSTFIX_SCHEMA(mapping) +using namespace ifcopenshell::geometry; + +#include "../../ifcgeom/profile_helper.h" +#include "../../ifcgeom/infra_sweep_helper.h" + +#ifdef SCHEMA_HAS_IfcSectionedSurface + + +taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSectionedSurface* inst) { + std::vector cross_sections; + + auto dir = map(inst->Directrix()); + auto pwf = taxonomy::dcast(dir); + if (!pwf) { + // Only implement on alignment curves + Logger::Warning("IfcSectionedSurface is only implemented for piecewise function Directrix curves", inst); + return nullptr; + } + + { + auto css = inst->CrossSections(); + auto csps = inst->CrossSectionPositions(); + std::vector faces; + + // The PointByDistanceExpressesions are factored out into (a) a cartesian offset relative to the + // reference frame along a certain curve location (b) the longitude. + + // The longitudes determine the range of the sweep and the offsets are interpolated in between + // sweep segments. + std::vector profile_offsets; + std::vector longitudes; + + for (auto& cs : *css) { + faces.push_back(std::move(taxonomy::cast(map(cs)))); + } +#ifdef SCHEMA_HAS_IfcPointByDistanceExpression + for (auto& csp : *csps) { + auto pbde = csp->Location()->as(true); + + longitudes.push_back(*pbde->DistanceAlong()->as(true) * length_unit_); + + // Corresponds to the profile X, Y directions (hopefully). + Eigen::Vector3d po( + pbde->OffsetLateral().get_value_or(0.), + // @todo I don't understand whether vertical is an offset relative to the tangent plane or to the global XY plane + pbde->OffsetVertical().get_value_or(0.), + 0. + ); + + profile_offsets.push_back(po); + } +#else + return nullptr; +#endif + if (faces.size() != profile_offsets.size()) { + Logger::Warning("Expected CrossSections and CrossSectionPositions to be equal length, but got " + std::to_string(faces.size()) + " and " + std::to_string(profile_offsets.size()) + " respectively", inst); + return nullptr; + } + if (faces.size() < 2) { + Logger::Warning("Expected at least two cross sections, but got " + std::to_string(faces.size()), inst); + return nullptr; + } + + for (size_t i = 0; i < faces.size(); ++i) { + cross_sections.push_back({ longitudes[i], faces[i], profile_offsets[i] }); + } + } + + return make_loft(settings_, inst, pwf, cross_sections); +} + +#endif diff --git a/src/ifcgeom/mapping/IfcSegmentedReferenceCurve.cpp b/src/ifcgeom/mapping/IfcSegmentedReferenceCurve.cpp index 2ab56482c1..d1ab88fbe5 100644 --- a/src/ifcgeom/mapping/IfcSegmentedReferenceCurve.cpp +++ b/src/ifcgeom/mapping/IfcSegmentedReferenceCurve.cpp @@ -21,6 +21,8 @@ #define mapping POSTFIX_SCHEMA(mapping) using namespace ifcopenshell::geometry; +#include "../piecewise_function_evaluator.h" + #ifdef SCHEMA_HAS_IfcSegmentedReferenceCurve taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSegmentedReferenceCurve* inst) { @@ -53,7 +55,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSegmentedReferenceCurve* ins const Eigen::Matrix4d& m = p->ccomponents(); double cant_start = m(0, 3); // start of cant curve - auto cant = taxonomy::make(cant_start,pwfs,&settings_); + auto cant = taxonomy::make(cant_start,pwfs); // Determine the valid domain of the PWF... the valid domain is where // horizontal, gradient and cant curves are defined @@ -68,11 +70,12 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSegmentedReferenceCurve* ins } // define the callback function for the segmented reference curve - auto composition = [gradient, cant](double u)->Eigen::Matrix4d { + piecewise_function_evaluator gradient_evaluator(gradient, &settings_), cant_evaluator(cant, &settings_); + auto composition = [gradient_evaluator, cant_evaluator, start = cant->start()](double u) -> Eigen::Matrix4d { // u is distance from start of cant curve // add cant->start() to u to get the distance from start of gradient curve - auto g = gradient->evaluate(u+cant->start()); - auto c = cant->evaluate(u); + auto g = gradient_evaluator.evaluate(u + start); + auto c = cant_evaluator.evaluate(u); // Need to multiply g and c so the axis vectors // from cant have the correct rotation applied so @@ -105,7 +108,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcSegmentedReferenceCurve* ins taxonomy::piecewise_function::spans_t spans; spans.emplace_back(length, composition); - auto pwf = taxonomy::make(start, spans, &settings_, inst); + auto pwf = taxonomy::make(start, spans, inst); return pwf; } diff --git a/src/ifcgeom/mapping/mapping.i b/src/ifcgeom/mapping/mapping.i index c6ed7a11f9..51e001f05d 100644 --- a/src/ifcgeom/mapping/mapping.i +++ b/src/ifcgeom/mapping/mapping.i @@ -135,6 +135,9 @@ BIND(IfcFixedReferenceSweptAreaSolid) #ifdef SCHEMA_HAS_IfcSectionedSolidHorizontal BIND(IfcSectionedSolidHorizontal) #endif +#ifdef SCHEMA_HAS_IfcSectionedSurface +BIND(IfcSectionedSurface) +#endif BIND(IfcCircle); BIND(IfcEllipse); diff --git a/src/ifcgeom/piecewise_function_evaluator.cpp b/src/ifcgeom/piecewise_function_evaluator.cpp new file mode 100644 index 0000000000..b85b165f21 --- /dev/null +++ b/src/ifcgeom/piecewise_function_evaluator.cpp @@ -0,0 +1,103 @@ +#include "piecewise_function_evaluator.h" +#include "profile_helper.h" + +using namespace ifcopenshell::geometry; + + +piecewise_function_evaluator::piecewise_function_evaluator(taxonomy::piecewise_function::const_ptr pwf, const ifcopenshell::geometry::Settings* settings) : pwf_(pwf) { + if (settings) { + settings_ = *settings; + } +} + +std::vector piecewise_function_evaluator::evaluation_points() const { + if (!eval_points_.has_value()) { + double curve_length = pwf_->length(); + + auto param_type = settings_.get().get(); + auto param = settings_.get().get(); + unsigned num_steps = 0; + if (param_type == ifcopenshell::geometry::settings::PiecewiseStepMethod::MAXSTEPSIZE) { + // parameter is max step size + num_steps = (unsigned)std::ceil(curve_length / param); + } else { + // parameter is minimum number of steps + num_steps = (unsigned)std::ceil(param); + } + + eval_points_ = evaluation_points(pwf_->start(), pwf_->start() + curve_length, num_steps); + } + return *eval_points_; +} + +std::vector piecewise_function_evaluator::evaluation_points(double ustart, double uend, unsigned nsteps) const { + double curve_length = pwf_->length(); + ustart = std::max(pwf_->start(), ustart); + uend = std::min(uend, pwf_->start() + curve_length); + + nsteps = std::max(1u, nsteps); // never have fewer than 1 step + + auto resolution = (uend - ustart) / nsteps; + + std::vector u_values; + u_values.reserve(nsteps); + + for (unsigned i = 0; i <= nsteps; ++i) { + auto u = resolution * i + ustart; + u_values.push_back(u); + } + + return u_values; +} + +taxonomy::item::ptr piecewise_function_evaluator::evaluate() const { + return evaluate(evaluation_points()); +} + +taxonomy::item::ptr piecewise_function_evaluator::evaluate(double ustart, double uend, unsigned nsteps) const { + return evaluate(evaluation_points(ustart, uend, nsteps)); +} + +Eigen::Matrix4d piecewise_function_evaluator::evaluate(double u) const { + // assume monotonic evaluation and store last evaluated segment + if (current_span_fn_ == nullptr || (u < current_span_start_ || current_span_end_ < u)) { + // there isn't a current span or u is outside the range of the current span + // get a new "current span" + std::tie(current_span_start_, current_span_end_, current_span_fn_) = get_span(u); + } + + u -= current_span_start_; // make u relative to start of span + return (*current_span_fn_)(u); +} + +taxonomy::item::ptr piecewise_function_evaluator::evaluate(const std::vector& dist) const { + std::vector polygon; + polygon.reserve(dist.size()); + for (auto& u : dist) { + Eigen::Matrix4d m = evaluate(u); + polygon.push_back(taxonomy::make(m(0, 3), m(1, 3), m(2, 3))); + } + + return polygon_from_points(polygon); +} + +std::tuple*> piecewise_function_evaluator::get_span(double u) const { + // force u to be within bounds of the curve + double s = pwf_->start(); + double e = pwf_->end(); + u = std::max(s, u); + u = std::min(u, e); + + double span_start = s; + for (auto& [length, fn] : pwf_->spans()) { + double span_end = span_start + length; + auto tolerance = settings_.get().get(); + if (span_start <= u && u < span_end + tolerance) { + return {span_start, span_end, &fn}; + } + span_start += length; + } + + Logger::Error("piecewise_function_impl::get_span span not found."); + return {0, 0, nullptr}; +} diff --git a/src/ifcgeom/piecewise_function_evaluator.h b/src/ifcgeom/piecewise_function_evaluator.h new file mode 100644 index 0000000000..3d91547268 --- /dev/null +++ b/src/ifcgeom/piecewise_function_evaluator.h @@ -0,0 +1,58 @@ +#ifndef ITERATOR_PWF_EVALUATOR_H +#define ITERATOR_PWF_EVALUATOR_H + +#include "../ifcgeom/taxonomy.h" + +#include + +namespace ifcopenshell { namespace geometry { + +/// @brief utility class to evaluate piecewise_function objects +class piecewise_function_evaluator { + public: + piecewise_function_evaluator(taxonomy::piecewise_function::const_ptr pwf, const ifcopenshell::geometry::Settings* settings=nullptr); + + /// @brief returns a vector of "distance along" points where the evaluate function computes loop points + std::vector evaluation_points() const; + + /// @brief returns a vector of "distance along" points between ustart and uend + /// @param ustart starting location + /// @param uend ending location + /// @param nsteps number of steps to evaluate + std::vector evaluation_points(double ustart, double uend, unsigned nsteps) const; + + /// @brief evaluates the piecewise function between start and end + /// evaluation point step size is taken from the settings object + taxonomy::item::ptr evaluate() const; + + /// @brief evaluates the piecewise function between ustart and uend + /// if ustart and uend are out of range, the range of values evaluated + /// are constrained to start_ and start_+length_ + /// @param ustart starting location + /// @param uend ending location + /// @param nsteps number of steps to evaluate + /// @return taxonomy::loop::ptr + taxonomy::item::ptr evaluate(double ustart, double uend, unsigned nsteps) const; + + /// @brief evaluates the piecewise function at u + /// @param u u is constrained to be between start_ and start_+length + /// @return 4x4 placement matrix + Eigen::Matrix4d evaluate(double u) const; + + private: + taxonomy::item::ptr evaluate(const std::vector& dist) const; + std::tuple*> get_span(double u) const; + + taxonomy::piecewise_function::const_ptr pwf_; + + ifcopenshell::geometry::Settings settings_; + + mutable double current_span_start_ = 0; + mutable double current_span_end_ = 0; + mutable const std::function* current_span_fn_ = nullptr; + mutable boost::optional> eval_points_; +}; + +}} + +#endif \ No newline at end of file diff --git a/src/ifcgeom/piecewise_function_impl.cpp b/src/ifcgeom/piecewise_function_impl.cpp index ecd2762057..6fa29fb649 100644 --- a/src/ifcgeom/piecewise_function_impl.cpp +++ b/src/ifcgeom/piecewise_function_impl.cpp @@ -7,97 +7,40 @@ namespace geometry { namespace taxonomy { -std::vector ifcopenshell::geometry::taxonomy::piecewise_function_impl::evaluation_points() const { - if (!eval_points_.has_value()) { - double curve_length = length(); +piecewise_function_impl::piecewise_function_impl(double start, const spans_t& s) : start_(start), spans_(s) { +} - auto param_type = settings_ ? settings_->get().get() : ifcopenshell::geometry::settings::PiecewiseStepMethod::MAXSTEPSIZE; - auto param = settings_ ? settings_->get().get() : 0.5; - unsigned num_steps = 0; - if (param_type == ifcopenshell::geometry::settings::PiecewiseStepMethod::MAXSTEPSIZE) { - // parameter is max step size - num_steps = (unsigned)std::ceil(curve_length / param); - } else { - // parameter is minimum number of steps - num_steps = (unsigned)std::ceil(param); - } - - eval_points_ = evaluation_points(start_, start_ + curve_length, num_steps); +piecewise_function_impl::piecewise_function_impl(double start, const std::vector& pwfs) : start_(start) { + for (auto& pwf : pwfs) { + spans_.insert(spans_.end(), pwf->spans().begin(), pwf->spans().end()); } - return *eval_points_; } -std::vector ifcopenshell::geometry::taxonomy::piecewise_function_impl::evaluation_points(double ustart, double uend, unsigned nsteps) const { - double curve_length = length(); - ustart = std::max(start_, ustart); - uend = std::min(uend, start_ + curve_length); +const piecewise_function_impl::spans_t& piecewise_function_impl::spans() const { return spans_; } - nsteps = std::max(1u, nsteps); // never have fewer than 1 step +bool piecewise_function_impl::is_empty() const { return spans_.empty(); } - auto resolution = (uend - ustart) / nsteps; - - std::vector u_values; - u_values.reserve(nsteps); - - for (unsigned i = 0; i <= nsteps; ++i) { - auto u = resolution * i + ustart; - u_values.push_back(u); - } - - return u_values; +double piecewise_function_impl::start() const { + return start_; } -ifcopenshell::geometry::taxonomy::item::ptr ifcopenshell::geometry::taxonomy::piecewise_function_impl::evaluate() const { - return evaluate(evaluation_points()); +double piecewise_function_impl::end() const { + return start_ + length(); } -item::ptr ifcopenshell::geometry::taxonomy::piecewise_function_impl::evaluate(double ustart, double uend, unsigned nsteps) const { - return evaluate(evaluation_points(ustart, uend, nsteps)); +double piecewise_function_impl::length() const { + return std::accumulate(spans_.begin(), spans_.end(), 0.0, [](const auto& v, const auto& s) { return v + s.first; }); + + // this is a secondary option where we only compute length once and cache it. + // mutex is needed to prevent interruption of the accumulation if there is multi-threading + // skipping this detail for now and just adding up the span lengths every time + //if (!length_.has_value()) { + // length_ = std::accumulate(spans_.begin(), spans_.end(), 0.0, [](const auto& v, const auto& s) { return v + s.first; }); + //} + //return *length_; } -item::ptr ifcopenshell::geometry::taxonomy::piecewise_function_impl::evaluate(const std::vector& dist) const { - std::vector polygon; - polygon.reserve(dist.size()); - for (auto& u : dist) { - Eigen::Matrix4d m = evaluate(u); - polygon.push_back(taxonomy::make(m.col(3)(0), m.col(3)(1), m.col(3)(2))); - } - - return polygon_from_points(polygon); -} - -Eigen::Matrix4d ifcopenshell::geometry::taxonomy::piecewise_function_impl::evaluate(double u) const { - // assume monotonic evaluation and store last evaluated segment - if (current_span_fn_ == nullptr || (u < current_span_start_ || current_span_end_ < u)) { - // there isn't a current span or u is outside the range of the current span - // get a new "current span" - std::tie(current_span_start_, current_span_end_, current_span_fn_) = get_span(u); - } - - u -= current_span_start_; // make u relative to start of span - return (*current_span_fn_)(u); -} - -std::tuple*> ifcopenshell::geometry::taxonomy::piecewise_function_impl::get_span(double u) const { - // force u to be within bounds of the curve - double s = start(); - double e = end(); - u = std::max(s, u); - u = std::min(u, e); - - double span_start = s; - for (auto& [length, fn] : spans_) { - double span_end = span_start + length; - auto tolerance = settings_ ? settings_->get().get() : 0.001; - if (span_start <= u && u < span_end + tolerance) { - return {span_start, span_end, &fn}; - } - span_start += length; - } - - Logger::Error("piecewise_function_impl::get_span span not found."); - return {0, 0, nullptr}; -} +piecewise_function_impl* piecewise_function_impl::clone_() const { return new piecewise_function_impl(*this); } } // namespace taxonomy diff --git a/src/ifcgeom/piecewise_function_impl.h b/src/ifcgeom/piecewise_function_impl.h index 0c2f306ac5..daf8636feb 100644 --- a/src/ifcgeom/piecewise_function_impl.h +++ b/src/ifcgeom/piecewise_function_impl.h @@ -12,79 +12,23 @@ namespace taxonomy { struct piecewise_function_impl { using spans_t = std::vector>>; - piecewise_function_impl(double start, const spans_t& s, ifcopenshell::geometry::Settings* settings = nullptr) : start_(start), - settings_(settings), - spans_(s){}; - piecewise_function_impl(double start, const std::vector& pwfs, ifcopenshell::geometry::Settings* settings = nullptr) : start_(start), - settings_(settings) { - for (auto& pwf : pwfs) { - spans_.insert(spans_.end(), pwf->spans().begin(), pwf->spans().end()); - } - }; + piecewise_function_impl(double start, const spans_t& s); + piecewise_function_impl(double start, const std::vector& pwfs); piecewise_function_impl(piecewise_function_impl&&) = default; piecewise_function_impl(const piecewise_function_impl&) = default; - const ifcopenshell::geometry::Settings* settings_ = nullptr; - - const spans_t& spans() const { return spans_; } - - bool is_empty() const { return spans_.empty(); } - - double start() const { - return start_; - } - - double end() const { - return start_ + length(); - } - - double length() const { - if (!length_.has_value()) { - length_ = std::accumulate(spans_.begin(), spans_.end(), 0.0, [](const auto& v, const auto& s) { return v + s.first; }); - } - return *length_; - } - - piecewise_function_impl* clone_() const { return new piecewise_function_impl(*this); } - - /// @brief returns a vector of "distance along" points where the evaluate function computes loop points - std::vector evaluation_points() const; - - /// @brief returns a vector of "distance along" points between ustart and uend - /// @param ustart starting location - /// @param uend ending location - /// @param nsteps number of steps to evaluate - std::vector evaluation_points(double ustart, double uend, unsigned nsteps) const; - - /// @brief evaluates the piecewise function between start and end - /// evaluation point step size is taken from the settings object - item::ptr evaluate() const; - - /// @brief evaluates the piecewise function between ustart and uend - /// if ustart and uend are out of range, the range of values evaluated - /// are constrained to start_ and start_+length_ - /// @param ustart starting location - /// @param uend ending location - /// @param nsteps number of steps to evaluate - /// @return taxonomy::loop::ptr - item::ptr evaluate(double ustart, double uend, unsigned nsteps) const; - - /// @brief evaluates the piecewise function at u - /// @param u u is constrained to be between start_ and start_+length - /// @return 4x4 placement matrix - Eigen::Matrix4d evaluate(double u) const; + const spans_t& spans() const; + bool is_empty() const; + double start() const; + double end() const; + double length() const; + piecewise_function_impl* clone_() const; private: - item::ptr evaluate(const std::vector& dist) const; - std::tuple*> get_span(double u) const; double start_ = 0.0; // starting value of the pwf spans_t spans_; - mutable double current_span_start_ = 0; - mutable double current_span_end_ = 0; - mutable const std::function* current_span_fn_ = nullptr; - mutable boost::optional length_; - mutable boost::optional> eval_points_; + //mutable boost::optional length_; // used for length() method }; } // namespace taxonomy diff --git a/src/ifcgeom/taxonomy.cpp b/src/ifcgeom/taxonomy.cpp index 818802dd2d..6f5e6249c7 100644 --- a/src/ifcgeom/taxonomy.cpp +++ b/src/ifcgeom/taxonomy.cpp @@ -313,7 +313,7 @@ namespace { } bool compare(const loft& a, const loft& b) { - return compare_collection(a, b); + return compare_collection(a, b); } bool compare(const collection& a, const collection& b) { @@ -464,12 +464,12 @@ ifcopenshell::geometry::taxonomy::solid::ptr ifcopenshell::geometry::create_box( } /////////////////// -piecewise_function::piecewise_function(double start, const spans_t& s, ifcopenshell::geometry::Settings* settings, const IfcUtil::IfcBaseInterface* instance) : implicit_item(instance) { - impl_ = new piecewise_function_impl(start, s, settings); +piecewise_function::piecewise_function(double start, const spans_t& s, const IfcUtil::IfcBaseInterface* instance) : implicit_item(instance) { + impl_ = new piecewise_function_impl(start, s); } -piecewise_function::piecewise_function(double start, const std::vector& pwfs, ifcopenshell::geometry::Settings* settings, const IfcUtil::IfcBaseInterface* instance) : implicit_item(instance) { - impl_ = new piecewise_function_impl(start, pwfs, settings); +piecewise_function::piecewise_function(double start, const std::vector& pwfs, const IfcUtil::IfcBaseInterface* instance) : implicit_item(instance) { + impl_ = new piecewise_function_impl(start, pwfs); }; piecewise_function::piecewise_function(const piecewise_function& other) : implicit_item(other) { @@ -486,11 +486,6 @@ double piecewise_function::start() const { return impl_->start(); } double piecewise_function::end() const { return impl_->end(); } double piecewise_function::length() const { return impl_->length(); } -std::vector piecewise_function::evaluation_points() const { return impl_->evaluation_points(); } -std::vector piecewise_function::evaluation_points(double ustart, double uend, unsigned nsteps) const { return impl_->evaluation_points(ustart, uend, nsteps); } -item::ptr piecewise_function::evaluate() const { return impl_->evaluate(); } -item::ptr piecewise_function::evaluate(double ustart, double uend, unsigned nsteps) const { return impl_->evaluate(ustart, uend, nsteps); } -Eigen::Matrix4d piecewise_function::evaluate(double u) const { return impl_->evaluate(u); } ifcopenshell::geometry::taxonomy::collection::ptr ifcopenshell::geometry::flatten(const taxonomy::collection::ptr& deep) { auto flat = make(); diff --git a/src/ifcgeom/taxonomy.h b/src/ifcgeom/taxonomy.h index 07385be746..13164daebf 100644 --- a/src/ifcgeom/taxonomy.h +++ b/src/ifcgeom/taxonomy.h @@ -347,8 +347,6 @@ typedef item const* ptr; struct implicit_item : public geom_item { DECLARE_PTR(implicit_item) using geom_item::geom_item; - - virtual item::ptr evaluate() const = 0; }; struct piecewise_function_impl; // forward declaration @@ -357,14 +355,12 @@ typedef item const* ptr; using spans_t = std::vector>>; - piecewise_function(double start, const spans_t& s, ifcopenshell::geometry::Settings* settings = nullptr, const IfcUtil::IfcBaseInterface* instance = nullptr); - piecewise_function(double start, const std::vector& pwfs, ifcopenshell::geometry::Settings* settings = nullptr, const IfcUtil::IfcBaseInterface* instance = nullptr); + piecewise_function(double start, const spans_t& s, const IfcUtil::IfcBaseInterface* instance = nullptr); + piecewise_function(double start, const std::vector& pwfs, const IfcUtil::IfcBaseInterface* instance = nullptr); piecewise_function(piecewise_function&&) = default; piecewise_function(const piecewise_function&); virtual ~piecewise_function(); - const ifcopenshell::geometry::Settings* settings_ = nullptr; - const spans_t& spans() const; bool is_empty() const; double start() const; @@ -379,33 +375,6 @@ typedef item const* ptr; return boost::hash{}(v); } - /// @brief returns a vector of "distance along" points where the evaluate function computes loop points - std::vector evaluation_points() const; - - /// @brief returns a vector of "distance along" points between ustart and uend - /// @param ustart starting location - /// @param uend ending location - /// @param nsteps number of steps to evaluate - std::vector evaluation_points(double ustart, double uend, unsigned nsteps) const; - - /// @brief evaluates the piecewise function between start and end - /// evaluation point step size is taken from the settings object - item::ptr evaluate() const override; - - /// @brief evaluates the piecewise function between ustart and uend - /// if ustart and uend are out of range, the range of values evaluated - /// are constrained to start_ and start_+length_ - /// @param ustart starting location - /// @param uend ending location - /// @param nsteps number of steps to evaluate - /// @return taxonomy::loop::ptr - item::ptr evaluate(double ustart, double uend, unsigned nsteps) const; - - /// @brief evaluates the piecewise function at u - /// @param u u is constrained to be between start_ and start_+length - /// @return 4x4 placement matrix - Eigen::Matrix4d evaluate(double u) const; - private: // note: it would be better if this were a std::unique_ptr, but that requires having the full definition // of piecewise_function_impl in this header file, which defeats the purpose of the PIMPL idiom. @@ -841,7 +810,7 @@ typedef item const* ptr; } }; - struct loft : public collection_base { + struct loft : public collection_base { DECLARE_PTR(loft) item::ptr axis; diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 7cfadb02dd..6239a44476 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -52,8 +52,8 @@ ifeq ($(PLATFORM), win64) PLATFORMTAG:=win_amd64 endif -IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v0.7.11-03935a9-$(PLATFORM).zip -IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v0.7.11-03935a9-$(PLATFORM).zip +IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v$(VERSION)-f5e02d1-$(PLATFORM).zip +IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(VERSION)-f5e02d1-$(PLATFORM).zip .PHONY: test test: diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py index a1c0b9a706..542347fa11 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/assign_cost_item_quantity.py @@ -104,12 +104,14 @@ class Usecase: if self.settings["prop_name"]: self.quantities = set(self.settings["cost_item"].CostQuantities or []) for product in self.settings["products"]: + if not product.is_a("IfcElement"): + continue self.assign_cost_control(related_object=product, cost_item=self.settings["cost_item"]) if self.settings["prop_name"]: if ( self.settings["cost_item"].CostQuantities and self.settings["cost_item"].CostQuantities[0].Name.lower() != self.settings["prop_name"].lower() - ) or not product.is_a("IfcObject"): + ): continue self.add_quantity_from_related_object(product) if self.settings["prop_name"]: diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/calculate_cost_item_resource_value.py b/src/ifcopenshell-python/ifcopenshell/api/cost/calculate_cost_item_resource_value.py index c58bd7e657..b160803d0a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/calculate_cost_item_resource_value.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/calculate_cost_item_resource_value.py @@ -102,9 +102,10 @@ def calculate_cost_item_resource_value(file: ifcopenshell.file, cost_item: ifcop for resource in resources: cost, unit = ifcopenshell.util.resource.get_cost(resource) if not cost: - cost, unit = ifcopenshell.util.resource.get_parent_cost( - resource - ) # Concept to standardise - Not defined in schema, but this makes manual scheduling of resources 10x faster and less duplicate data. + # Concept to standardise - Not defined in schema, but this makes manual scheduling of resources 10x faster and less duplicate data. + parent_cost = ifcopenshell.util.resource.get_parent_cost(resource) + assert parent_cost + cost, unit = parent_cost quantity = ifcopenshell.util.resource.get_quantity(resource) if not cost or not quantity: continue diff --git a/src/ifcopenshell-python/ifcopenshell/api/profile/copy_profile.py b/src/ifcopenshell-python/ifcopenshell/api/profile/copy_profile.py index 61d3c7d6cc..0da06f2beb 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/profile/copy_profile.py +++ b/src/ifcopenshell-python/ifcopenshell/api/profile/copy_profile.py @@ -42,6 +42,6 @@ def copy_profile(file: ifcopenshell.file, profile: ifcopenshell.entity_instance) inverses = file.get_inverse(profile) psets = [i for i in inverses if i.is_a("IfcProfileProperties")] for pset in psets: - new_pset = ifcopenshell.util.element.copy_deep(file, pset, exclude=["IfcProfileDef"]) + new_pset = ifcopenshell.util.element.copy(file, pset) new_pset.ProfileDefinition = new_profile return new_profile diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/edit_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/edit_pset.py index 797f0afba3..6de7ca5f44 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/edit_pset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/edit_pset.py @@ -167,6 +167,9 @@ def edit_pset( class Usecase: + file: ifcopenshell.file + settings: dict[str, Any] + def execute(self) -> None: self.update_pset_name() self.load_pset_template() @@ -378,7 +381,7 @@ class Usecase: properties.append(self.file.create_entity("IfcPropertySingleValue", **args)) return properties - def assign_new_properties(self, props: ifcopenshell.entity_instance) -> None: + def assign_new_properties(self, props: list[ifcopenshell.entity_instance]) -> None: if hasattr(self.settings["pset"], "HasProperties"): self.settings["pset"].HasProperties = props diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/unshare_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/unshare_pset.py index acf955879a..051905b2b7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/unshare_pset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/unshare_pset.py @@ -87,7 +87,8 @@ def unshare_pset( for product in products: # No need to consider about profile/material properties since # they are assigned to 1 element directly and therefore cannot be shared. - pset_copy = ifcopenshell.util.element.copy_deep(file, pset) + # Don't copy_deep to keep it light - edit_pset supports unsharing shared props. + pset_copy = ifcopenshell.util.element.copy(file, pset) pset_copies.append(pset_copy) ifcopenshell.api.pset.assign_pset(file, [product], pset_copy) diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index 8804586acd..61d20457fd 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -445,7 +445,7 @@ def get_elements_by_pset(pset: ifcopenshell.entity_instance) -> set[ifcopenshell """Retrieve the elements (or element types) that are using the provided property set.""" is_ifc2x3 = pset.file.schema == "IFC2X3" elements = set() - if pset.is_a("IfcPropertySet"): + if pset.is_a("IfcPropertySet") or pset.is_a("IfcQuantitySet"): rels = pset.PropertyDefinitionOf if is_ifc2x3 else pset.DefinesOccurrence for rel in rels: elements.update(rel.RelatedObjects) diff --git a/src/ifcopenshell-python/ifcopenshell/util/resource.py b/src/ifcopenshell-python/ifcopenshell/util/resource.py index adce5bfa3f..8a444e6371 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/resource.py +++ b/src/ifcopenshell-python/ifcopenshell/util/resource.py @@ -166,9 +166,9 @@ def get_quantity(resource: ifcopenshell.entity_instance) -> float: return duration.total_seconds() / 3600 -def get_parent_cost(resource: ifcopenshell.entity_instance) -> Union[None, tuple[float, Union[str, None]]]: - if not resource.Nests: +def get_parent_cost(resource: ifcopenshell.entity_instance) -> Union[tuple[float, Union[str, None]], None]: + if not (nests := resource.Nests): return else: - cost = get_cost(resource.Nests[0].RelatingObject) + cost = get_cost(nests[0].RelatingObject) return cost diff --git a/src/ifcopenshell-python/ifcopenshell/util/sequence.py b/src/ifcopenshell-python/ifcopenshell/util/sequence.py index f476df5792..720bebfb22 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/sequence.py +++ b/src/ifcopenshell-python/ifcopenshell/util/sequence.py @@ -36,7 +36,18 @@ RECURRENCE_TYPE = Literal[ ] -def derive_date(task, attribute_name, date=None, is_earliest=False, is_latest=False): +def derive_date( + task: ifcopenshell.entity_instance, + attribute_name: str, + date=None, + is_earliest: bool = False, + is_latest: bool = False, +): + """ + + :param task: IfcTask. + + """ if task.TaskTime: current_date = ( ifcopenshell.util.date.ifc2datetime(getattr(task.TaskTime, attribute_name)) @@ -268,7 +279,7 @@ def get_task_work_schedule(task: ifcopenshell.entity_instance) -> Union[ifcopens def get_nested_tasks(task: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: - return [object for rel in task.IsNestedBy or [] for object in rel.RelatedObjects] + return [object for rel in task.IsNestedBy or [] for object in rel.RelatedObjects if object.is_a("IfcTask")] def get_parent_task(task: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: @@ -448,12 +459,9 @@ def get_related_products( """Gets the related products being output by a task :param relating_product: One of the products already output by the task. - :type relating_product: ifcopenshell.entity_instance, optional :param related_object: The IfcTask that you want to get all the related products for. - :type related_object: ifcopenshell.entity_instance, optional :return: A set of IfcProducts output by the IfcTask. - :rtype: set[ifcopenshell.entity_instance] Example: @@ -478,17 +486,18 @@ def get_related_products( products = ifcopenshell.util.sequence.get_related_products(related_object=task) """ + assert relating_product or related_object, "Either relating_product or related_object must be provided." + products = set() - related_object = None - if related_object: - related_object = related_object - elif relating_product: + if not related_object and relating_product: for reference in relating_product.ReferencedBy: if reference.is_a("IfcRelAssignsToProduct"): related_object = reference.RelatedObjects[0] + if related_object: assignments = related_object.HasAssignments for assignment in assignments: if assignment.is_a("IfcRelAssignsToProduct"): products.add(assignment.RelatingProduct.id()) + return products diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 822f012925..05cae93995 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -368,6 +368,7 @@ class ShapeBuilder: "ProfileType": profile_type, "OuterCurve": outer_curve, } + if self.file.schema == "IFC2X3": kwargs["Position"] = self.create_axis2_placement_2d() diff --git a/src/ifcparse/IfcFile.cpp b/src/ifcparse/IfcFile.cpp index 830754444f..79c3d25f6f 100644 --- a/src/ifcparse/IfcFile.cpp +++ b/src/ifcparse/IfcFile.cpp @@ -130,10 +130,48 @@ namespace { if (aggregate_storage.which() == 0) { aggregate_storage = std::vector>{ v }; } else { - auto* vec_ptr = boost::get>>(&aggregate_storage); - if (vec_ptr) { + if (auto* vec_ptr = boost::get>>(&aggregate_storage)) { vec_ptr->push_back(v); } else { + if constexpr (std::is_same_v, int>) { + auto* vec_ptr2 = boost::get>(&aggregate_storage); + if (vec_ptr2) { + // double[] + int + vec_ptr2->push_back((double) v); + } + } + if constexpr (std::is_same_v, double>) { + auto* vec_ptr2 = boost::get>(&aggregate_storage); + if (vec_ptr2) { + // int[] -> double[] + double + std::vector ps(vec_ptr2->begin(), vec_ptr2->end()); + ps.push_back(v); + aggregate_storage = ps; + } + } + + if constexpr (std::is_same_v, std::vector>) { + auto* vec_ptr2 = boost::get>>(&aggregate_storage); + if (vec_ptr2) { + // double[][] + int[] + std::vector vd(v.begin(), v.end()); + vec_ptr2->push_back(vd); + } + } + if constexpr (std::is_same_v, std::vector>) { + auto* vec_ptr2 = boost::get>>(&aggregate_storage); + if (vec_ptr2) { + // int[][] -> double[][] + double[] + std::vector> vvd; + for (auto& vv : *vec_ptr2) { + std::vector vd(vv.begin(), vv.end()); + vvd.push_back(vd); + } + vvd.push_back(v); + aggregate_storage = vvd; + } + } + // @todo would be cool if we can trace this back to file offset auto current = boost::apply_visitor([](auto v) { if constexpr (!std::is_same_v) { @@ -145,6 +183,7 @@ namespace { return std::string{}; } }, aggregate_storage); + Logger::Error("Inconsistent aggregate valuation while attempting to append " + std::string(typeid(decltype(v)).name()) + " to an aggregate of " + current); // @todo boolean -> logical upgrade diff --git a/src/ifcparse/IfcParse.cpp b/src/ifcparse/IfcParse.cpp index 0ea64c2f78..a922775977 100644 --- a/src/ifcparse/IfcParse.cpp +++ b/src/ifcparse/IfcParse.cpp @@ -709,7 +709,7 @@ void IfcParse::IfcFile::load(unsigned entity_instance_name, const IfcParse::enti load(entity_instance_name, entity, context.push(), attribute_index == -1 ? (int) attribute_index_within_data : attribute_index); } else { return_value++; - if (TokenFunc::isIdentifier(next)) { + if (TokenFunc::isIdentifier(next) && entity) { register_inverse(entity_instance_name, entity, next, attribute_index == -1 ? attribute_index_within_data : attribute_index); } diff --git a/src/ifcpatch/ifcpatch/recipes/TessellateElements.py b/src/ifcpatch/ifcpatch/recipes/TessellateElements.py index 0769e2027e..06929cea59 100644 --- a/src/ifcpatch/ifcpatch/recipes/TessellateElements.py +++ b/src/ifcpatch/ifcpatch/recipes/TessellateElements.py @@ -92,7 +92,7 @@ class Patcher: ) -> None: geometry = getattr(shape, "geometry", shape) v = [[x.tolist() for x in ifcopenshell.util.shape.get_vertices(geometry)]] - f = [ifcopenshell.util.shape.get_faces(geometry)] + f = [ifcopenshell.util.shape.get_faces(geometry).tolist()] replacements[element] = (v, f) iterator = ifcopenshell.geom.iterator(settings, self.file, multiprocessing.cpu_count(), include=products) @@ -112,6 +112,8 @@ class Patcher: # Do the replacements outside the iterator to prevent messing up iterator state. for element, geometry in replacements.items(): v, f = geometry + if not v or not f: + continue mesh = ifcopenshell.api.run( "geometry.add_mesh_representation", self.file, diff --git a/src/ifcpatch/test/test_TesselateElements.py b/src/ifcpatch/test/test_TesselateElements.py new file mode 100644 index 0000000000..83ea349137 --- /dev/null +++ b/src/ifcpatch/test/test_TesselateElements.py @@ -0,0 +1,84 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.util.representation +import ifcpatch +import ifcopenshell +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.profile +import ifcopenshell.api.root +import ifcopenshell.api.unit +import test.bootstrap +import ifcopenshell.geom +import ifcopenshell.util.element +from ifcopenshell.util.shape_builder import ShapeBuilder + + +class TestTesselateElements(test.bootstrap.IFC4): + def test_run(self): + is_ifc2x3 = self.file.schema == "IFC2X3" + ifcopenshell.api.root.create_entity(self.file, "IfcProject") + wall = ifcopenshell.api.root.create_entity(self.file, "IfcWall") + model = ifcopenshell.api.context.add_context(self.file, "Model") + body = ifcopenshell.api.context.add_context( + self.file, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=model, + ) + + # Add a length unit just for tests. + unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(self.file, units=[unit]) + + builder = ShapeBuilder(self.file) + rect = builder.polyline(builder.get_rectangle_coords(), closed=True) + extrusion = builder.extrude(rect, 1.0) + rep = builder.get_representation(body, extrusion) + ifcopenshell.api.geometry.assign_representation(self.file, wall, rep) + original_rep_id = rep.id() + + ifcpatch.execute({"file": self.file, "recipe": "TessellateElements", "arguments": ["IfcWall"]}) + + # Original representation still exists. + assert self.file.by_id(original_rep_id) + + other_reps = [r for r in ifcopenshell.util.representation.get_representations_iter(wall) if r != rep] + assert len(other_reps) == 1 + new_rep = other_reps[0] + new_rep.ContextOfItems = body + new_rep.RepresentationIdentifier = "Body" + new_rep.RepresentationType = "Tesselation" + assert len(items := new_rep.Items) == 1 + item = items[0] + if is_ifc2x3: + assert item.is_a("IfcFacetedBrep") + assert len(faces := item.Outer.CfsFaces) == 12 + for face in faces: + assert len(face.Bounds[0].Bound.Polygon) == 3 + else: + assert item.is_a("IfcPolygonalFaceSet") + assert len(faces := item.Faces) == 12 + for face in faces: + assert len(face.CoordIndex) == 3 + + +class TestTesselateElementsIFC2X3(test.bootstrap.IFC2X3, TestTesselateElements): + pass diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index 0f26e28db7..ca0851324f 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -246,6 +246,7 @@ namespace { %include "../ifcgeom/Iterator.h" %include "../ifcgeom/GeometrySerializer.h" %include "../ifcgeom/taxonomy.h" +%include "../ifcgeom/piecewise_function_evaluator.h" %include "../serializers/SvgSerializer.h" %include "../serializers/HdfSerializer.h" @@ -315,7 +316,7 @@ assign_children_access(loop, edge); assign_children_access(face, loop); assign_children_access(shell, face); assign_children_access(solid, shell); -assign_children_access(loft, face); +assign_children_access(loft, geom_item); assign_children_access(boolean_result, geom_item); %define assign_matrix_access(item_name) diff --git a/src/ifcwrap/IfcPython.i b/src/ifcwrap/IfcPython.i index b3b6cc3a85..f70388663d 100644 --- a/src/ifcwrap/IfcPython.i +++ b/src/ifcwrap/IfcPython.i @@ -84,6 +84,7 @@ %{ #include "../ifcgeom/Iterator.h" #include "../ifcgeom/taxonomy.h" + #include "../ifcgeom/piecewise_function_evaluator.h" #ifdef IFOPSH_WITH_OPENCASCADE #include "../ifcgeom/Serialization/Serialization.h" #include "../ifcgeom/kernels/opencascade/IfcGeomTree.h" @@ -159,6 +160,7 @@ %module ifcopenshell_wrapper %{ #include "../ifcgeom/Converter.h" #include "../ifcgeom/taxonomy.h" + #include "../ifcgeom/piecewise_function_evaluator.h" #ifdef IFOPSH_WITH_OPENCASCADE #include "../ifcgeom/Serialization/Serialization.h" #include "../ifcgeom/kernels/opencascade/IfcGeomTree.h"