diff --git a/.github/workflows/ci-bcf-pypi.yml b/.github/workflows/ci-bcf-pypi.yml index 4b70c77e0a..75034acc2f 100644 --- a/.github/workflows/ci-bcf-pypi.yml +++ b/.github/workflows/ci-bcf-pypi.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v2 # https://github.com/actions/checkout - uses: actions/setup-python@v2 # https://github.com/actions/setup-python with: - python-version: '3.10' # Version range or exact version of a Python version to use, using SemVer's version range syntax + python-version: '3.11' # Version range or exact version of a Python version to use, using SemVer's version range syntax architecture: 'x64' # optional x64 or x86. Defaults to x64 if not specified - run: echo ${{ env.DATE }} - name: Get current date diff --git a/.github/workflows/ci-bonsai.yml b/.github/workflows/ci-bonsai.yml index 362f8ecbda..1bf217da89 100644 --- a/.github/workflows/ci-bonsai.yml +++ b/.github/workflows/ci-bonsai.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - pyver: [py310, py311, py312] + pyver: [py311, py312] config: - { name: "Windows Build", @@ -32,15 +32,15 @@ jobs: } - { name: "Linux Build", - short_name: linux + short_name: linux, } - { name: "MacOS Build", - short_name: macos + short_name: macos, } - { name: "MacOS ARM Build", - short_name: macosm1 + short_name: macosm1, } steps: - uses: actions/checkout@v2 @@ -53,8 +53,7 @@ jobs: run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT - name: Compile run: | - cd src/bonsai && - make dist PLATFORM=${{ matrix.config.short_name }} PYVERSION=${{ matrix.pyver }} IS_STABLE=TRUE + cd src/bonsai && make dist PLATFORM=${{ matrix.config.short_name }} PYVERSION=${{ matrix.pyver }} IS_STABLE=TRUE - name: Find zip file name id: find_zip run: | diff --git a/README.md b/README.md index 619e403cd7..935cd7e94d 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ For more information, see: * [IfcOpenShell Python Hello World Tutorial](https://docs.ifcopenshell.org/ifcopenshell-python/hello_world.html) * [Bonsai Website](https://bonsaibim.org) * [Bonsai Documentation](https://docs.bonsaibim.org/index.html) - * [Add-on Installation](https://docs.bonsaibim.org/users/installation.html) - * [Exploring an IFC model](https://docs.bonsaibim.org/users/exploring_an_ifc_model.html) + * [Add-on Installation](https://docs.bonsaibim.org/quickstart/installation.html) + * [Exploring an IFC model](https://docs.bonsaibim.org/quickstart/explore_model.html) Development is sponsored through your generous donations! diff --git a/VERSION b/VERSION index b4d6d12101..a3df0a6959 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.11 +0.8.0 diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 608af5a235..524ac9f91c 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -81,7 +81,7 @@ BLENDER_PLATFORM:=windows-x64 endif # Current build commit hash. -OLD:=7e6607a +OLD:=03935a9 .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 fe49b03bdd..9d1651b0a9 100644 --- a/src/bonsai/bonsai/bim/data/webui/sioserver.py +++ b/src/bonsai/bonsai/bim/data/webui/sioserver.py @@ -166,6 +166,16 @@ class BlenderNamespace(socketio.AsyncNamespace): blender_messages[sid]["cost_schedules"] = data await sio.emit("cost_schedules", {"blenderId": sid, "data": data}, namespace="/web") + async def on_cost_values(self, sid, data): + print(f"Cost values from Blender client {sid}") + blender_messages[sid]["cost_values"] = data + await sio.emit("cost_values", {"blenderId": sid, "data": data}, namespace="/web") + + async def on_cost_value(self, sid, data): + print(f"Cost values from Blender client {sid}") + blender_messages[sid]["cost_value"] = data + await sio.emit("cost_value", {"blenderId": sid, "data": data}, namespace="/web") + async def schedules(request): with open("templates/index.html", "r") as f: template = f.read() diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/components/contextMenu.css b/src/bonsai/bonsai/bim/data/webui/static/css/components/contextMenu.css new file mode 100644 index 0000000000..79ba11cc68 --- /dev/null +++ b/src/bonsai/bonsai/bim/data/webui/static/css/components/contextMenu.css @@ -0,0 +1,89 @@ + +th { + position: relative; +} +.resizer { + position: absolute; + right: 0; + top: 0; + width: 5px; + height: 100%; + cursor: col-resize; + user-select: none; +} +.context-menu { + display: none; + position: absolute; + background-color: rgb(51, 51, 51); + border: 1px solid #ccc; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + z-index: 1000; + color: white; +} +.context-menu button { + display: block; + width: 100%; + padding: 8px; + border: none; + background: none; + text-align: left; + cursor: pointer; +} +.context-menu button:hover { + background-color: #f0f0f0; + color: black; +} + +/* Context menu button is clicked, should zoom into object and change color */ +.context-menu button:active { + background-color: #ff9634; + color: rgba(37, 51, 77, 0.466); + /* zoom */ + transform: scale(0.9); +} + +#cost-items tr:hover { + background-color: #f0f0f0; + color : black; +} + +.form-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + background-color: #363636; + padding: 10px; +} + +.cost-values-form { + position: absolute; /* Set initial position to absolute */ + background-color: white; + border: 1px solid black; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + z-index: 9999; + cursor: move; /* Change cursor to move */ +} + +.cost-values-table { + width: 100%; + border-collapse: collapse; +} + +.cost-values-table th, .cost-values-table td { + border: 1px solid #ddd; + padding: 8px; +} + +.cost-values-table th { + background-color: #f2f2f2; + text-align: left; +} + +.close-button { + position: absolute; + top: 10px; + right: 10px; + font-size: 20px; + cursor: pointer; +} \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/data/webui/static/css/gantt.css b/src/bonsai/bonsai/bim/data/webui/static/css/gantt.css index 035fd68f0b..36575b8c74 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/css/gantt.css +++ b/src/bonsai/bonsai/bim/data/webui/static/css/gantt.css @@ -1,4 +1,5 @@ @import url("./components/card.css"); +@import url("./components/contextMenu.css"); :root { --font-family: Arial, sans-serif; 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 1ea91590ed..5a10017b89 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/cost.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/cost.js @@ -23,6 +23,8 @@ function connectSocket() { socket.on("connect", handleWebConnect); socket.on("cost_schedules", handleCostSchedulesData); socket.on("cost_items", handleCostItemsData); + socket.on("cost_values", handleCostValuesData); + socket.on("cost_value", handleCostValueData); } function handleBlenderConnect(blenderId) { @@ -46,8 +48,20 @@ function handleBlenderDisconnect(blenderId) { }); } +function removeTableElement(blenderId) { + $("#cost-items-" + blenderId).remove(); +} +function handleCostValueData(data) { + + const costItemId = data.data["cost_value"]["cost_item_id"]; + const costValueId = data.data["cost_value"]["cost_value_id"]; + console.log("Handling cost value data", costItemId, costValueId); + CostUI.addNewCostValueRow(costItemId, costValueId); + +} + function handleConnectedClients(data) { $("#blender-count").text(data.length); @@ -56,6 +70,29 @@ function handleConnectedClients(data) { }); } +function handleCostValuesData(data) { + CostUI.createCostValuesForm({ + costValues: data.data["cost_values"]["cost_values"], + costItemId: data.data["cost_values"]["cost_item_id"], + callbacks: { + 'editCostValues': editCostValues, + 'addCostValue': addCostValue, + }, + }); +} + +function addCostValue(costItemId) { + executeOperator({ type: "addCostValue", costItemId: costItemId }); +} + +function editCostValues(costItemId, costValues) { + executeOperator({ + type: "editCostValues", + costItemId: costItemId, + costValues: costValues + }); +} + function handleThemeData(themeData) { function arrayToRgbString(arr) { const [r, g, b, a] = arr.map((num) => Math.round(num * 255)); @@ -99,7 +136,6 @@ function setTheme(theme) { } function addCostItem(costItemId) { - console.log("addCostItem", costItemId); executeOperator({ type: "addCostItem", costItemId: costItemId }); } @@ -119,11 +155,7 @@ function handleCostSchedulesData(data) { const blenderId = data.blenderId; const costSchedules = data.data["cost_schedules"]["cost_schedules"]; const currency = data.data["cost_schedules"]["currency"]["name"]; - - console.log(data.data["cost_schedules"]); - const costScheduleDiv = $("#cost-schedules"); - costSchedules.forEach((costSchedule) => { costSchedule.UpdateDate = new Date(costSchedule.UpdateDate); const mainContainer = CostUI.text("Updated On: " + costSchedule.UpdateDate); @@ -136,7 +168,6 @@ function handleCostSchedulesData(data) { } function handleCostItemsData(data) { - console.log(data); CostUI.createCostSchedule({ data: data.data["cost_items"], blenderID: data.blenderId, @@ -144,6 +175,7 @@ function handleCostItemsData(data) { "addCostItem": addCostItem, "selectAssignedElements": selectAssignedElements, 'editCostItemName': editCostItemName, + 'enableEditingCostValues': enableEditingCostValues, } }); } @@ -166,3 +198,7 @@ function loadCostSchedule(costScheduleId, blenderId) { function getCostSchedules(blenderId) { executeOperator({ type: "getCostSchedules" }, blenderId); } + +function enableEditingCostValues(costItemId) { + executeOperator({ type: "enableEditingCostValues", costItemId: costItemId}); +} \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js b/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js index 5942765dfd..473cab82d9 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/gantt.js @@ -82,7 +82,6 @@ function handleBlenderDisconnect(blenderId) { function handleConnectedClients(data) { $("#blender-count").text(data.length); - // console.log(data); data.forEach(function (id) { connectedClients[id] = { shown: false, @@ -115,7 +114,6 @@ function handleThemeData(themeData) { } const cssRule = generateCssVariableRule(themeData.theme); - console.log(cssRule); var styleElement = $("#gantt-stylesheet")[0]; if (styleElement) { @@ -133,7 +131,6 @@ function handleWorkScheduleData(data) { const workSchedules = data["data"]["work_schedule_info"]; workSchedules.forEach((workSchedule) => { - console.log(workSchedule); const mainContainer = CostUI.text(new Date(workSchedule.CreationDate).toLocaleDateString()); const callback = () => loadWorkSchedule(workSchedule.id); const card = CostUI.createCard(workSchedule.Name,mainContainer, callback); @@ -142,11 +139,8 @@ function handleWorkScheduleData(data) { } function handleGanttData(data) { - console.log("running handleGanttData"); const blenderId = data["blenderId"]; - console.log(data); - const filename = data["data"]["ifc_file"]; const ganttTasks = data["data"]["gantt_data"]["tasks"]; const ganttWorkSched = data["data"]["gantt_data"]["work_schedule"]; @@ -183,7 +177,6 @@ function handleDefaultData(data) { const blenderId = data["blenderId"]; const isDirty = data["data"]["is_dirty"]; showWarning(blenderId, isDirty); - console.log('default data',data); } // Function to add a new gantt with data and filename 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 61779592bd..7ec2d6f582 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 @@ -1,14 +1,6 @@ export class CostUI { constructor() {} - createButton() { - console.log("Button created"); - } - - createInput() { - console.log("Input created"); - } - static isCostScheduleLoaded(id) { const existingTable = document.getElementById('cost-items-' + id); return existingTable !== null; @@ -17,7 +9,7 @@ export class CostUI { static removeCostSchedule(id) { document.getElementById("cost-items-" + id).remove(); } - static createTable(id) { + static createTable(id, callbacks) { CostUI.isCostScheduleLoaded(id) ? CostUI.removeCostSchedule(id) : null; const table = document.createElement("table"); @@ -51,7 +43,7 @@ export class CostUI { CostUI.addTableStyles(id); // Create context menu - CostUI.createContextMenu(); + CostUI.createContextMenu(callbacks); table.get_blender_id = function() { return this.getAttribute("id").split("-")[2]; @@ -71,52 +63,19 @@ export class CostUI { #cost-items-${id} td:not(:nth-child(1)) { width: 100px; /* Set a fixed width for other columns */ } - th { - position: relative; - } - .resizer { - position: absolute; - right: 0; - top: 0; - width: 5px; - height: 100%; - cursor: col-resize; - user-select: none; - } - .context-menu { - display: none; - position: absolute; - background-color: white; - border: 1px solid #ccc; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); - z-index: 1000; - } - .context-menu button { - display: block; - width: 100%; - padding: 8px; - border: none; - background: none; - text-align: left; - cursor: pointer; - } - .context-menu button:hover { - background-color: #f0f0f0; - } - #cost-items tr:hover { - background-color: #f0f0f0; /* Change this color to your desired hover color */ - } + `; document.head.appendChild(style); } - static createContextMenu() { + static createContextMenu(callbacks) { // Create context menu const contextMenu = document.createElement("div"); contextMenu.id = "context-menu"; contextMenu.classList.add("context-menu"); contextMenu.innerHTML = ` - + + `; @@ -126,6 +85,7 @@ export class CostUI { document.addEventListener("contextmenu", function(event) { event.preventDefault(); const targetRow = event.target.closest("tr"); + const targetCell = event.target.closest("td"); if (targetRow && targetRow.parentElement.id === "cost-items") { const contextMenu = document.getElementById("context-menu"); contextMenu.style.display = "block"; @@ -134,6 +94,7 @@ export class CostUI { // Store the target row in the context menu for later use contextMenu.targetRow = targetRow; + contextMenu.targetCell = targetCell; } else { document.getElementById("context-menu").style.display = "none"; } @@ -146,14 +107,32 @@ export class CostUI { } }); - document.getElementById("edit-button").addEventListener("click", function() { + const editCostValuesButton = document.getElementById("edit-cost-values-button"); + editCostValuesButton.addEventListener("click", function() { + console.log("Edit cost values executed"); const targetRow = document.getElementById("context-menu").targetRow; + const targetCell = document.getElementById("context-menu").targetCell; if (targetRow) { - // Implement your edit action here - console.log("Edit row:", targetRow.getAttribute("id")); + const costItemId = parseInt(targetRow.getAttribute("id")); + callbacks.enableEditingCostValues ? callbacks.enableEditingCostValues(costItemId) : null; } }); + const addButton = document.getElementById("add-cost-item-button"); + if (!addButton.dataset.listenerAdded) { + function addCostItemHandler(e) { + e.stopPropagation(); + const targetRow = document.getElementById("context-menu").targetRow; + if (targetRow) { + const costItemId = parseInt(targetRow.getAttribute("id")); + callbacks.addCostItem ? callbacks.addCostItem(costItemId) : null; + } + document.getElementById("context-menu").style.display = "none"; + } + addButton.addEventListener("click", addCostItemHandler); + addButton.dataset.listenerAdded = "true"; + } + document.getElementById("delete-button").addEventListener("click", function() { const targetRow = document.getElementById("context-menu").targetRow; if (targetRow) { @@ -246,7 +225,7 @@ export class CostUI { } static createCostSchedule({ data, blenderID, title, callbacks = {} }) { - const [table, tbody] = CostUI.createTable(blenderID); + const [table, tbody] = CostUI.createTable(blenderID, callbacks); CostUI.createCostItem(data, tbody, 0, null, callbacks); CostUI.applyExpandedState(); } @@ -263,6 +242,35 @@ export class CostUI { } static createRow(obj, nestingLevel, parentID, callbacks = {}) { + const row = CostUI.createTableRow(obj, nestingLevel, parentID); + const expandButton = CostUI.createExpandButton(obj); + const nameCell = CostUI.createNameCell(obj, nestingLevel, expandButton, callbacks); + const totalCostQuantityCell = CostUI.createTableCell(obj.TotalCostQuantity); + const unitSymbolCell = CostUI.createTableCell(obj.UnitSymbol); + const totalAppliedValueCell = CostUI.createTableCell(obj.TotalAppliedValue); + const totalCostCell = CostUI.createTotalCostCell(obj); + const flexContainerCell = CostUI.createFlexContainerCell(obj, callbacks); + + row.appendChild(nameCell); + row.appendChild(totalCostQuantityCell); + row.appendChild(unitSymbolCell); + row.appendChild(totalAppliedValueCell); + row.appendChild(totalCostCell); + row.appendChild(flexContainerCell); + + row.get_id = function() { + return this.getAttribute("id"); + }; + + row.get_parent = function() { + const parentId = this.getAttribute("parent-id"); + return parentId ? document.getElementById(parentId) : null; + }; + + return row; + } + + static createTableRow(obj, nestingLevel, parentID) { const row = document.createElement("tr"); row.setAttribute("id", obj.id); row.setAttribute("parent-id", parentID); @@ -270,6 +278,10 @@ export class CostUI { row.classList.add("nested"); row.classList.add(`level-${nestingLevel}`); } + return row; + } + + static createExpandButton(obj) { const expandButton = document.createElement("button"); expandButton.classList.add("toggle-button"); if (obj.is_nested_by && obj.is_nested_by.length > 0) { @@ -277,51 +289,62 @@ export class CostUI { } else { expandButton.style.visibility = "hidden"; } - //row.appendChild(expandButton); - expandButton.addEventListener("click", function() { CostUI.contractExpandRow.call(this, obj.id); }); - + return expandButton; + } + + static createNameCell(obj, nestingLevel, expandButton, callbacks) { const nameCell = document.createElement("td"); const nameInput = document.createElement("input"); nameInput.value = obj.name ? obj.name : "Unnamed"; - + nameInput.addEventListener("change", function() { callbacks.editCostItemName ? callbacks.editCostItemName(obj.id, this.value) : null; }); - + nameInput.addEventListener("keydown", function(event) { if (event.key === "Enter") { callbacks.editCostItemName ? callbacks.editCostItemName(obj.id, this.value) : null; } }); + nameCell.style.paddingLeft = nestingLevel * 20 + "px"; nameCell.appendChild(expandButton); nameCell.appendChild(nameInput); - row.appendChild(nameCell); - - const totalCostQuantityCell = document.createElement("td"); - totalCostQuantityCell.textContent = obj.TotalCostQuantity; - row.appendChild(totalCostQuantityCell); - - const unitSymbolCell = document.createElement("td"); - unitSymbolCell.textContent = obj.UnitSymbol; - row.appendChild(unitSymbolCell); - - const totalAppliedValueCell = document.createElement("td"); - totalAppliedValueCell.textContent = obj.TotalAppliedValue; - row.appendChild(totalAppliedValueCell); - + return nameCell; + } + + static createTableCell(content) { + const cell = document.createElement("td"); + cell.textContent = content; + return cell; + } + + static createTotalCostCell(obj) { const totalCostCell = document.createElement("td"); const totalCost = parseFloat(obj.TotalCost).toFixed(2); - totalCostCell.textContent = obj.is_sum ? totalCost + " (Σ)" : totalCost; - - row.appendChild(totalCostCell); - + return totalCostCell; + } + + static createFlexContainerCell(obj, callbacks) { const divFlex = document.createElement("div"); divFlex.classList.add("flex-container"); + + const addButton = CostUI.createAddButton(obj, callbacks); + const selectButton = CostUI.createSelectButton(obj, callbacks); + + divFlex.appendChild(addButton); + divFlex.appendChild(selectButton); + + const flexContainerCell = document.createElement("td"); + flexContainerCell.appendChild(divFlex); + return flexContainerCell; + } + + static createAddButton(obj, callbacks) { const addButton = document.createElement("button"); addButton.textContent = "+"; addButton.classList.add("add-button"); @@ -329,31 +352,17 @@ export class CostUI { e.stopPropagation(); callbacks.addCostItem ? callbacks.addCostItem(obj.id) : null; }); - + return addButton; + } + + static createSelectButton(obj, callbacks) { const selectButton = document.createElement("button"); selectButton.textContent = "Select"; selectButton.addEventListener("click", function(e) { e.stopPropagation(); callbacks.selectAssignedElements ? callbacks.selectAssignedElements(obj.id) : null; }); - - divFlex.appendChild(addButton); - divFlex.appendChild(selectButton); - - const flexContainerCell = document.createElement("td"); - flexContainerCell.appendChild(divFlex); - row.appendChild(flexContainerCell); - - row.get_id = function() { - return this.getAttribute("id"); - }; - - row.get_parent = function() { - const parentId = this.getAttribute("parent-id"); - return parentId ? document.getElementById(parentId) : null; - }; - - return row; + return selectButton; } static hideNestedRows(parentId) { @@ -461,4 +470,267 @@ export class CostUI { return card; } + + static createCostValuesForm({ costItemId, costValues, callbacks }) { + // Check if the form already exists and remove it if it does + const formId = "cost-values-form-" + costItemId; + let existingForm = document.getElementById(formId); + if (existingForm) { + existingForm.remove(); + } + + const form = CostUI.createFormElement(formId, "cost-values-form"); + + const header = CostUI.createHeader("Cost Values Form"); + form.appendChild(header); + + const table = CostUI.createCostValuesTable(costItemId, ["Type", "Category", "Value"]); + + costValues.forEach(costValue => { + const tr = CostUI.createCostvaluesRow(costItemId, costValue); + table.appendChild(tr); + }); + + form.appendChild(table); + + const addButton = CostUI.createAddCostValueButton(costItemId, callbacks); + form.appendChild(addButton); + + const submitButton = CostUI.createSubmitButton(callbacks); + form.appendChild(submitButton); + + const closeButton = CostUI.createCloseButton(form); + form.appendChild(closeButton); + + CostUI.makeHeaderDraggable(header, form); + + document.body.appendChild(form); + + return form; + } + + + static getCostValuesTable(costItemId) { + return document.getElementById("cost-values-table-" + costItemId); + } + + static addNewCostValueRow(costItemId, costValueId) { + const table = CostUI.getCostValuesTable(costItemId); + // check if table exists + if (!table) { + console.log("Cost values table not found for cost item ID:", costItemId); + return; + } + console.log("Adding new cost value row", costValueId); + console.log(table) + const tr = CostUI.createCostvaluesRow(costItemId, {"category": "", "name": "", "applied_value": 0, "id": costValueId, "parent": costItemId}); + table.appendChild(tr); + } + + static createCostValuesTable(costItemId, headers) { + const table = document.createElement("table"); + table.classList.add("cost-values-table"); + table.id = "cost-values-table-" + costItemId; + + const headerRow = document.createElement("tr"); + headers.forEach(headerText => { + const th = document.createElement("th"); + th.textContent = headerText; + headerRow.appendChild(th); + }); + table.appendChild(headerRow); + + return table; + } + + static createCostvaluesRow(costItemId,costValue) { + + function cleanLabel(value) { + // remove anything which is not a dot or a digit + return value.replace(/[^0-9.]/g, ''); + + } + + + if (document.getElementById(costValue.id)) { + return document.getElementById(costValue.id); + } + + const tr = document.createElement("tr"); + tr.isCostValue = true; + tr.parent = costItemId; + tr.id = costValue.id; + + let costType = "FIXED"; + if (costValue.category === "*") { + costType = "SUM"; + } + else if (costValue.category && costValue.category !== "*") { + costType = "CATEGORY"; + } + else if (costValue.applied_value) { + costType = "FIXED"; + } + + const typeCell = CostUI.createTableDropdown("type", costType); + const dropdown = typeCell.querySelector("select"); + dropdown.addEventListener("change", function() { + const selectedType = this.value; + CostUI.updateRowBasedOnType(selectedType, categoryCell, valueCell1); + }); + tr.appendChild(typeCell); + + const categoryCell = CostUI.createTableInput("text", "category", costValue.category); + tr.appendChild(categoryCell); + + let value + + if (costValue.category === "*"){ + value = cleanLabel(costValue.label) + } + else { + value = costValue.applied_value + } + const valueCell1 = CostUI.createTableInput("number", "value", value); + tr.appendChild(valueCell1); + // Apply initial state based on costType + CostUI.updateRowBasedOnType(costType, categoryCell, valueCell1); + + return tr; + } + + static updateRowBasedOnType(type, categoryCell, valueCell1) { + if (type === "FIXED") { + categoryCell.querySelector("input").disabled = true; + categoryCell.querySelector("input").style.backgroundColor = "lightgrey"; + valueCell1.querySelector("input").disabled = false; + valueCell1.querySelector("input").style.backgroundColor = ""; + } else if (type === "CATEGORY") { + categoryCell.querySelector("input").disabled = false; + categoryCell.querySelector("input").style.backgroundColor = ""; + valueCell1.querySelector("input").disabled = false; + valueCell1.querySelector("input").style.backgroundColor = ""; + } else if (type === "SUM") { + categoryCell.querySelector("input").disabled = true; + categoryCell.querySelector("input").style.backgroundColor = "lightgrey"; + valueCell1.querySelector("input").disabled = true; + valueCell1.querySelector("input").style.backgroundColor = "lightgrey"; + } + } + + static createTableDropdown(name, 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; + option.textContent = optionValue; + if (optionValue === value) { + option.selected = true; + } + dropdown.appendChild(option); + }); + + cell.appendChild(dropdown); + return cell; + } + + static createTableInput(type, name, value) { + const cell = document.createElement("td"); + const input = document.createElement("input"); + input.type = type; + input.name = name; + input.value = value; + cell.appendChild(input); + return cell; + } + + static createAddCostValueButton(costItemId, callbacks) { + const addButton = document.createElement("button"); + addButton.textContent = "+"; + addButton.classList.add("add-button"); + addButton.addEventListener("click", function(e) { + e.preventDefault(); + callbacks.addCostValue ? callbacks.addCostValue(costItemId) : null; + }); + return addButton; + } + + static createSubmitButton(callbacks) { + const submitButton = document.createElement("button"); + submitButton.type = "submit"; + submitButton.textContent = "Save"; + + submitButton.addEventListener("click", function(e) { + e.preventDefault(); + const form = this.closest("form"); + const costValues = []; + const rows = Array.from(form.querySelectorAll("tr")).filter(row => row.isCostValue); + + rows.forEach(row => { + const costCategory = row.querySelector("input[name='category']").value; + const appliedValue = parseFloat(row.querySelector("input[name='value']").value); + const costType = row.querySelector("select[name='type']").value; + const id = parseInt(row.id); + costValues.push({ costType, costCategory, appliedValue, id: id, costItemId: row.parent }); + }); + const costItemId = parseInt(form.id.split("-")[3]); + console.log(form.id) + console.log("Cost values to be saved:", costItemId); + callbacks.editCostValues ? callbacks.editCostValues(costItemId, costValues) : null; + form.remove(); + }); + return submitButton; + } + + static createFormElement(id, className) { + const form = document.createElement("form"); + form.id = id; + form.classList.add(className); + form.style.position = "absolute"; // Set initial position to absolute + form.style.top = "50%"; + form.style.left = "50%"; + form.style.transform = "translate(-50%, -50%)"; + return form; + } + + static createHeader(text) { + const header = document.createElement("div"); + header.classList.add("form-header"); + header.textContent = text; + return header; + } + + static createCloseButton(form) { + const closeButton = document.createElement("span"); + closeButton.classList.add("close-button"); + closeButton.innerHTML = "×"; + closeButton.addEventListener("click", function() { + form.remove(); + }); + return closeButton; + } + + static makeHeaderDraggable(header, form) { + header.addEventListener("mousedown", function(e) { + let offsetX = e.clientX - form.getBoundingClientRect().left; + let offsetY = e.clientY - form.getBoundingClientRect().top; + + function mouseMoveHandler(e) { + form.style.left = `${e.clientX - offsetX}px`; + form.style.top = `${e.clientY - offsetY}px`; + } + + function mouseUpHandler() { + document.removeEventListener("mousemove", mouseMoveHandler); + document.removeEventListener("mouseup", mouseUpHandler); + } + + document.addEventListener("mousemove", mouseMoveHandler); + document.addEventListener("mouseup", mouseUpHandler); + }); + } } diff --git a/src/bonsai/bonsai/bim/data/webui/templates/costing.html b/src/bonsai/bonsai/bim/data/webui/templates/costing.html index 6bd6ddeee7..c0297bfac4 100644 --- a/src/bonsai/bonsai/bim/data/webui/templates/costing.html +++ b/src/bonsai/bonsai/bim/data/webui/templates/costing.html @@ -4,8 +4,7 @@ BlenderBIM Web UI - - + . import os -import re import bpy import json import time @@ -43,13 +42,12 @@ import bonsai.core.drawing as core import bonsai.bim.module.drawing.svgwriter as svgwriter import bonsai.bim.module.drawing.annotation as annotation import bonsai.bim.module.drawing.sheeter as sheeter -import bonsai.bim.module.drawing.scheduler as scheduler -import bonsai.bim.module.drawing.helper as helper import bonsai.bim.export_ifc from bonsai.bim.module.drawing.decoration import CutDecorator from bonsai.bim.module.drawing.data import DecoratorData, DrawingsData from typing import NamedTuple, List, Union, Optional, Literal from lxml import etree +from math import radians from mathutils import Vector, Color, Matrix from timeit import default_timer as timer from bonsai.bim.module.drawing.prop import RasterStyleProperty, RASTER_STYLE_PROPERTIES_EXCLUDE @@ -257,6 +255,7 @@ class CreateDrawing(bpy.types.Operator): self.svg_writer.camera_projection = tuple( self.camera.matrix_world.to_quaternion() @ Vector((0, 0, -1)) ) + self.svg_writer.calculate_scale() self.svg_writer.setup_drawing_resource_paths(self.camera_element) @@ -269,7 +268,12 @@ class CreateDrawing(bpy.types.Operator): with profile("Generate linework"): if tool.Drawing.is_camera_orthographic(): - linework_svg = self.generate_linework(context) + if self.camera.data.BIMCameraProperties.linework_mode == "OPENCASCADE": + linework_svg = self.generate_linework(context) + elif self.camera.data.BIMCameraProperties.linework_mode == "FREESTYLE": + linework_svg = self.generate_freestyle_linework(context) + elif self.camera.data.BIMCameraProperties.linework_mode == "FREESTYLE": + linework_svg = self.generate_freestyle_linework(context) with profile("Generate annotation"): if tool.Drawing.is_camera_orthographic(): @@ -546,6 +550,156 @@ class CreateDrawing(bpy.types.Operator): self.file.createIfcDirection(forward.tolist()), ) + def generate_bisect_linework(self, context: bpy.types.Context, root): + camera_matrix_i = context.scene.camera.matrix_world.inverted() + + group = root.find("{http://www.w3.org/2000/svg}g") + raw_width, raw_height = self.get_camera_dimensions() + x_offset = raw_width / 2 + y_offset = raw_height / 2 + svg_scale = self.scale * 1000 # IFC is in meters, SVG is in mm + + for obj in context.visible_objects: + if obj.type != "MESH": + continue + if not (element := tool.Ifc.get_entity(obj)): + continue + if not tool.Drawing.is_intersecting_camera(obj, context.scene.camera): + continue + verts, edges = tool.Drawing.bisect_mesh(obj, context.scene.camera) + + g = etree.SubElement(root, "{http://www.w3.org/2000/svg}g") + g.attrib["{http://www.ifcopenshell.org/ns}guid"] = element.GlobalId + g.attrib["{http://www.ifcopenshell.org/ns}name"] = element.Name or "" + + lines = [] + for edge in edges: + start = [o for o in (camera_matrix_i @ Vector(verts[edge[0]])).xy] + end = [o for o in (camera_matrix_i @ Vector(verts[edge[1]])).xy] + coords = [start, end] + d = " ".join( + ["L{},{}".format((x_offset + p[0]) * svg_scale, (y_offset - p[1]) * svg_scale) for p in coords] + ) + d = "M{}".format(d[1:]) + path = etree.SubElement(g, "{http://www.w3.org/2000/svg}path") + path.attrib["d"] = d + group.append(g) + + def generate_freestyle_linework(self, context: bpy.types.Context) -> str | None: + if not ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasLinework"): + return + svg_path = self.get_svg_path(cache_type="linework") + if os.path.isfile(svg_path) and self.props.should_use_linework_cache: + return svg_path + + context.scene.render.engine = "BLENDER_WORKBENCH" + context.scene.render.use_freestyle = True + context.scene.svg_export.use_svg_export = True + + linesets = context.view_layer.freestyle_settings.linesets + if len(linesets) == 1 and linesets[0].name == "LineSet": + context.view_layer.freestyle_settings.crease_angle = radians(140) + context.view_layer.freestyle_settings.use_culling = True + lineset = linesets[0] + lineset.edge_type_negation = "EXCLUSIVE" + lineset.select_silhouette = False + lineset.select_crease = False + lineset.select_border = False + lineset.select_edge_mark = False + lineset.select_contour = False + lineset.select_external_contour = False + lineset.select_material_boundary = False + lineset.select_suggestive_contour = True + lineset.select_ridge_valley = True + + edge_mesh = bpy.data.meshes.new("Temp Merged Edges") + edge_obj = bpy.data.objects.new("Temp Merged Edges", edge_mesh) + context.scene.collection.objects.link(edge_obj) + edge_bm = bmesh.new() + + visible_object_names = {obj.name for obj in bpy.context.visible_objects} + for obj in bpy.context.view_layer.objects: + is_visible = obj.name in visible_object_names + obj.hide_render = not is_visible + if ( + is_visible + and obj.type == "MESH" + and len(obj.data.edges) + and not len(obj.data.polygons) + and not obj.name.startswith("IfcAnnotation") + ): + tmp_mesh = None + try: + tmp_mesh = obj.data.copy() + tmp_mesh.transform(obj.matrix_world) + edge_bm.from_mesh(tmp_mesh) + finally: + if tmp_mesh: + bpy.data.meshes.remove(tmp_mesh) + + ret = bmesh.ops.extrude_edge_only(edge_bm, edges=edge_bm.edges) + verts_extruded = [e for e in ret["geom"] if isinstance(e, bmesh.types.BMVert)] + + cam_z = self.camera.matrix_world.to_3x3() @ self.camera.data.view_frame(scene=None)[-1].normalized() + cam_z *= 0.001 + + for v in verts_extruded: + v.co += cam_z + + edge_bm.to_mesh(edge_mesh) + edge_bm.free() + + actual_path = svg_path[0:-4] + "0001.svg" + context.scene.render.filepath = svg_path[0:-4] + bpy.ops.render.render(write_still=False) + + os.replace(actual_path, svg_path) + + bpy.data.objects.remove(edge_obj) + bpy.data.meshes.remove(edge_mesh) + + context.scene.render.use_freestyle = False + context.scene.svg_export.use_svg_export = False + + tree = etree.parse(svg_path) + root = tree.getroot() + + freestyle_width = float(root.attrib["width"]) + freestyle_height = float(root.attrib["height"]) + svg_width = self.svg_writer.width + svg_height = self.svg_writer.height + + group = root.find(".//{http://www.w3.org/2000/svg}g") + group.attrib["class"] = "projection" + + # Resize Freestyle to our proper width / height and purge all other attributes + for path in root.findall(".//{http://www.w3.org/2000/svg}path"): + for key in path.attrib: + if key == "fill": + continue + elif key != "d": + del path.attrib[key] + continue + d = path.attrib[key] + coords = d.strip().split()[1:] + new_d = "M" + for i in range(0, len(coords), 2): + x = float(coords[i][:-1]) + y = float(coords[i + 1]) + x = x / freestyle_width * svg_width + y = y / freestyle_height * svg_height + new_d += f" {x},{y}" + path.attrib["d"] = new_d + pass + + self.generate_bisect_linework(context, root) + self.merge_linework_and_add_metadata(root) + + with open(svg_path, "wb") as svg: + svg.write(etree.tostring(root)) + + return svg_path + def generate_linework(self, context: bpy.types.Context) -> Union[str, None]: if not ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasLinework"): return @@ -632,10 +786,15 @@ class CreateDrawing(bpy.types.Operator): return svg_path - self.move_projection_to_bottom(root) - self.merge_linework_and_add_metadata(root) + if self.camera.data.BIMCameraProperties.cut_mode == "BISECT": + self.remove_cut_linework(root) + self.generate_bisect_linework(context, root) + self.merge_linework_and_add_metadata(root) + elif self.camera.data.BIMCameraProperties.cut_mode == "OPENCASCADE": + self.move_projection_to_bottom(root) + self.merge_linework_and_add_metadata(root) - if self.camera.data.BIMCameraProperties.calculate_shapely_surfaces: + if self.camera.data.BIMCameraProperties.fill_mode == "SHAPELY": # shapely variant group = root.find("{http://www.w3.org/2000/svg}g") nm = group.attrib["{http://www.ifcopenshell.org/ns}name"] @@ -720,7 +879,7 @@ class CreateDrawing(bpy.types.Operator): path.set("class", " ".join(list(classes))) group.insert(0, path) - if self.camera.data.BIMCameraProperties.calculate_svgfill_surfaces: + if self.camera.data.BIMCameraProperties.fill_mode == "SVGFILL": results = etree.tostring(root).decode("utf8") svg_data_1 = results from xml.dom.minidom import parseString @@ -885,7 +1044,6 @@ class CreateDrawing(bpy.types.Operator): group = root.findall(".//{http://www.w3.org/2000/svg}g")[0] - self.svg_writer.calculate_scale() x_offset = self.svg_writer.raw_width / 2 y_offset = self.svg_writer.raw_height / 2 @@ -990,6 +1148,7 @@ class CreateDrawing(bpy.types.Operator): bm = bmesh.new() bm.from_mesh(obj.data) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.000001) for edge in bm.edges: if not edge.is_manifold: bm.free() @@ -1010,6 +1169,11 @@ class CreateDrawing(bpy.types.Operator): except: continue + def remove_cut_linework(self, root): + for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"): + if "projection" not in el.get("class", "").split(): + el.getparent().remove(el) + def merge_linework_and_add_metadata(self, root): join_criteria = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "JoinCriteria") if join_criteria: @@ -1073,10 +1237,34 @@ class CreateDrawing(bpy.types.Operator): if results: for path in old_paths: path.getparent().remove(path) + + # polygonize_full will create polygons for everything, including + # interior "holes". As a result we do two passes. The first pass + # records polygon interior rings. The second pass uses this to + # check if the exterior ring matches an interior ring. If it does, + # it's a hole. Skip it! + + interior_hashes = set() for result in results: for geom in result.geoms: - path = etree.SubElement(el, "{http://www.w3.org/2000/svg}path") if isinstance(geom, shapely.Polygon): + for interior in geom.interiors: + # Sorted because coordinate ordering may differ, + # and frozenset because shapely sometimes emits + # duplicate coordinates. + interior_hashes.add(hash(frozenset(sorted(interior.coords)))) + elif isinstance(geom, shapely.LineString): + path = etree.SubElement(el, "{http://www.w3.org/2000/svg}path") + d = "M" + " L".join([",".join([str(o) for o in co]) for co in geom.coords]) + " Z" + path.attrib["d"] = d + + for result in results: + for geom in result.geoms: + if isinstance(geom, shapely.Polygon): + path = etree.SubElement(el, "{http://www.w3.org/2000/svg}path") + if hash(frozenset(sorted(geom.exterior.coords))) in interior_hashes: + # This is a "hole", as its exterior perfectly matches an interior. + continue d = ( "M" + " L".join([",".join([str(o) for o in co]) for co in geom.exterior.coords[0:-1]]) @@ -1088,9 +1276,7 @@ class CreateDrawing(bpy.types.Operator): + " L".join([",".join([str(o) for o in co]) for co in interior.coords[0:-1]]) + " Z" ) - elif isinstance(geom, shapely.LineString): - d = "M" + " L".join([",".join([str(o) for o in co]) for co in geom.coords]) + " Z" - path.attrib["d"] = d + path.attrib["d"] = d # Architectural convention only merges these objects. E.g. pipe segments and fittings shouldn't merge. if not element.is_a("IfcWall") and not element.is_a("IfcSlab"): @@ -1629,7 +1815,9 @@ class ActivateDrawing(bpy.types.Operator): if ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "HasUnderlay"): bpy.ops.bim.reload_drawing_styles() bpy.ops.bim.activate_drawing_style() - core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=tool.Ifc.get().by_id(self.drawing)) + + if tool.Drawing.is_camera_orthographic(): + core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=tool.Ifc.get().by_id(self.drawing)) CutDecorator.install(context) tool.Drawing.show_decorations() diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index 0ae5e45aac..e67c8a908f 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -390,8 +390,31 @@ class DocProperties(PropertyGroup): class BIMCameraProperties(PropertyGroup): - calculate_shapely_surfaces: BoolProperty(name="Calculate Shapely Surfaces", default=False) - calculate_svgfill_surfaces: BoolProperty(name="Calculate SVGFill Surfaces", default=False) + linework_mode: EnumProperty( + items=[ + ("OPENCASCADE", "OpenCASCADE", "Slower, more accurate, with more features"), + ("FREESTYLE", "Freestyle", "Faster, less accurate, no fill support"), + ], + default="OPENCASCADE", + name="Linework Mode", + ) + fill_mode: EnumProperty( + items=[ + ("NONE", "None", "Disable filling areas seen in projection"), + ("SHAPELY", "Shapely", "Recommended"), + ("SVGFILL", "SVGFill", "Experimental"), + ], + default="NONE", + name="Fill Mode", + ) + cut_mode: EnumProperty( + items=[ + ("BISECT", "Bisect", "Faster, more forgiving to bad geometry"), + ("OPENCASCADE", "OpenCASCADE", "More technically correct"), + ], + default="BISECT", + name="Cut Mode", + ) has_underlay: BoolProperty(name="Underlay", default=False, update=update_has_underlay) has_linework: BoolProperty(name="Linework", default=True, update=update_has_linework) has_annotation: BoolProperty(name="Annotation", default=True, update=update_has_annotation) diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 9b26ad0f5b..7f7651ea5b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -64,9 +64,15 @@ class BIM_PT_camera(Panel): row.prop(dprops, "should_use_annotation_cache", text="", icon="FILE_REFRESH") row = self.layout.row() - row.prop(props, "calculate_shapely_surfaces") - row = self.layout.row() - row.prop(props, "calculate_svgfill_surfaces") + row.prop(props, "linework_mode") + if props.linework_mode == "OPENCASCADE": + row = self.layout.row() + row.prop(props, "fill_mode") + row = self.layout.row() + row.prop(props, "cut_mode") + elif not hasattr(context.scene, "svg_export"): + row = self.layout.row() + row.label(text="Freestyle SVG Exporter Not Installed", icon="ERROR") row = self.layout.row() row.prop(props, "width") diff --git a/src/bonsai/bonsai/bim/module/light/prop.py b/src/bonsai/bonsai/bim/module/light/prop.py index 1ab8f332d5..cccc3d6c95 100644 --- a/src/bonsai/bonsai/bim/module/light/prop.py +++ b/src/bonsai/bonsai/bim/module/light/prop.py @@ -52,41 +52,28 @@ def get_sites(self, context): def update_latlong(self, context): - sun_props = context.scene.sun_pos_properties - sun_props.latitude = self.latitude - sun_props.longitude = self.longitude - update_sun_path() + update_sun_path(self) def update_hourminute(self, context): - sun_props = context.scene.sun_pos_properties - sun_props.time = self.hour + (self.minute / 60) - update_sun_path() + update_sun_path(self) def update_date(self, context): - sun_props = context.scene.sun_pos_properties - sun_props.month = self.month - sun_props.day = self.day - update_sun_path() + update_sun_path(self) def update_true_north(self, context): - sun_props = context.scene.sun_pos_properties - # Preserve IFC sign convention - sun_props.north_offset = radians(self.true_north * -1) - update_sun_path() + update_sun_path(self) def update_sun_path_size(self, context): - sun_props = context.scene.sun_pos_properties - sun_props.sun_distance = self.sun_path_size - update_sun_path() + update_sun_path(self) def update_display_shadows(self, context): if self.display_shadows: - update_sun_path() + update_sun_path(self) context.scene.render.engine = "BLENDER_WORKBENCH" context.scene.display.shading.light = "FLAT" context.scene.display.shading.show_shadows = True @@ -102,7 +89,7 @@ def update_display_shadows(self, context): def update_display_sun_path(self, context): if self.display_sun_path: - update_sun_path() + update_sun_path(self) SolarDecorator.install(bpy.context) else: SolarDecorator.uninstall() @@ -113,7 +100,7 @@ def update_resolution(self, context): context.scene.render.resolution_y = self.radiance_resolution_y -def update_sun_path(): +def update_sun_path(self): if not SolarData.is_loaded: SolarData.load() @@ -122,6 +109,16 @@ def update_sun_path(): props = bpy.context.scene.BIMSolarProperties sun_props = bpy.context.scene.sun_pos_properties + + sun_props.sun_distance = self.sun_path_size + sun_props.latitude = self.latitude + sun_props.longitude = self.longitude + sun_props.month = self.month + sun_props.day = self.day + sun_props.time = self.hour + (self.minute / 60) + # Preserve IFC sign convention + sun_props.north_offset = radians(self.true_north * -1) + props.timezone = tzfpy.get_tz(props.longitude, props.latitude) timezone = pytz.timezone(props.timezone) dt = datetime.datetime(sun_props.year, sun_props.month, sun_props.day, props.hour, props.minute) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 5c810a247c..e3b15e8327 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -375,9 +375,8 @@ class PolylineDecorator: default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z last_point_data = polyline_data[len(polyline_data) - 1] except: - last_point_data = None - second_to_last_point_data = None default_container_elevation = 0 + last_point_data = None snap_prop = context.scene.BIMModelProperties.snap_mouse_point[0] @@ -387,9 +386,14 @@ class PolylineDecorator: last_point = Vector((0, 0, 0)) if is_input_on: - snap_vector = Vector( - (float(cls.input_panel["X"]), float(cls.input_panel["Y"]), default_container_elevation) - ) + 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)) @@ -405,18 +409,22 @@ class PolylineDecorator: 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)) + 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(snap_vector, last_point, second_to_last_point, degrees=True) + 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, 4)) - cls.input_panel["Y"] = str(round(snap_vector.y, 4)) + 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, 4)) - cls.input_panel["D"] = str(round(distance, 4)) - cls.input_panel["A"] = str(round(angle, 4)) + 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 @@ -470,13 +478,13 @@ class PolylineDecorator: try: polyline_data = context.scene.BIMModelProperties.polyline_point 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: - return + 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)) - last_point = Vector((last_point_data.x, last_point_data.y, last_point_data.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( @@ -485,35 +493,33 @@ class PolylineDecorator: 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 - 10, last_point.y, last_point.z)) + 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_rad = radians(180 - float(cls.input_panel["A"])) - ref_vec = second_to_last_point - last_point - dir_vec = last_point - snap_vector + angle = radians(float(cls.input_panel["A"])) - rot_axis = ref_vec.cross(dir_vec) - rot_axis.normalize() - rot_axis = Vector((abs(rot_axis.x), abs(rot_axis.y), abs(rot_axis.z))) + rot_vector = tool.Cad.angle_3_vectors(second_to_last_point, last_point, snap_vector, angle, degrees=True) - rot_mat = Matrix.Rotation(angle_rad, 3, rot_axis) - - ref_vec.normalize() - coords = ((ref_vec @ rot_mat) * distance) + last_point + 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, 4)) - cls.input_panel["Y"] = str(round(y, 4)) + 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, 4)) + 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 draw_batch(self, shader_type, content_pos, color, indices=None): @@ -522,9 +528,8 @@ class PolylineDecorator: shader.uniform_float("color", color) batch.draw(shader) - def draw_input_panel(self, context): - texts = {"D": "Distance:", "A": "Angle:", "X": "X coord:", "Y": "Y coord:", "Z": "Z coord:", "AREA": "Area:"} - + @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 @@ -532,6 +537,15 @@ class PolylineDecorator: 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): + texts = {"D": "Distance: ", "A": "Angle: ", "X": "X coord: ", "Y": "Y coord: ", "Z": "Z coord:", "AREA": "Area: "} self.addon_prefs = tool.Blender.get_addon_preferences() self.font_id = 0 @@ -546,11 +560,7 @@ class PolylineDecorator: if key != "A" and key != self.input_type: value = float(value) - if context.scene.unit_settings.length_unit == "MILLIMETERS": - value = value * 1000 - formatted_value = format_distance( - value * factor, precision=precision, suppress_zero_inches=True, in_unit_length=True - ) + formatted_value = self.format_input_panel_units(context, value) else: formatted_value = value @@ -582,25 +592,15 @@ 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) - 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 - value = measurement_prop[i].dim value = float(value) - if context.scene.unit_settings.length_unit == "MILLIMETERS": - value = value * 1000 - formatted_value = format_distance( - value * factor, precision=precision, suppress_zero_inches=True, in_unit_length=True - ) + formatted_value = self.format_input_panel_units(context, value) blf.position(self.font_id, coords_dim[0], coords_dim[1], 0) blf.draw(self.font_id, "d: " + formatted_value) + if i == 1: + continue pos_angle = measurement_prop[i - 1].position coords_angle = view3d_utils.location_3d_to_region_2d(region, rv3d, pos_angle) blf.position(self.font_id, coords_angle[0], coords_angle[1], 0) diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index 4e9a826104..1483b47482 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -40,7 +40,8 @@ import json import collections -def update_door_modifier_representation(context: bpy.types.Context, obj: bpy.types.Object) -> None: +def update_door_modifier_representation(context: bpy.types.Context) -> None: + obj = context.active_object props = obj.BIMDoorProperties element = tool.Ifc.get_entity(obj) ifc_file = tool.Ifc.get() @@ -534,10 +535,9 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_door" bl_label = "Add Door" bl_options = {"REGISTER"} - obj: bpy.props.StringProperty() def _execute(self, context): - obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object + obj = context.active_object element = tool.Ifc.get_entity(obj) props = obj.BIMDoorProperties @@ -558,7 +558,7 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator): pset=pset, properties={"Data": tool.Ifc.get().createIfcText(json.dumps(door_data, default=list))}, ) - update_door_modifier_representation(context, obj) + update_door_modifier_representation(context) return {"FINISHED"} @@ -612,7 +612,7 @@ class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator): props.is_editing = False - update_door_modifier_representation(context, obj) + update_door_modifier_representation(context) pset = tool.Pset.get_element_pset(element, "BBIM_Door") door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list)) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 1c54a0ab34..83c594c177 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -429,7 +429,15 @@ class DrawPolylineWall(bpy.types.Operator): 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.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() @@ -437,7 +445,13 @@ class DrawPolylineWall(bpy.types.Operator): self.recalculate_inputs(context) self.is_input_on = True self.input_type = "D" - self.number_input = [] + + 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() @@ -507,6 +521,7 @@ class DrawPolylineWall(bpy.types.Operator): 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) diff --git a/src/bonsai/bonsai/bim/module/model/window.py b/src/bonsai/bonsai/bim/module/model/window.py index 257a2e1349..6e35896814 100644 --- a/src/bonsai/bonsai/bim/module/model/window.py +++ b/src/bonsai/bonsai/bim/module/model/window.py @@ -36,7 +36,8 @@ from bmesh.types import BMVert from mathutils import Vector -def update_window_modifier_representation(context, obj): +def update_window_modifier_representation(context): + obj = context.active_object element = tool.Ifc.get_entity(obj) props = obj.BIMWindowProperties ifc_file = tool.Ifc.get() @@ -427,10 +428,9 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_window" bl_label = "Add Window" bl_options = {"REGISTER"} - obj: bpy.props.StringProperty() def _execute(self, context): - obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object + obj = context.active_object element = tool.Ifc.get_entity(obj) props = obj.BIMWindowProperties @@ -450,7 +450,7 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator): pset=pset, properties={"Data": tool.Ifc.get().createIfcText(json.dumps(window_data, default=list))}, ) - update_window_modifier_representation(context, obj) + update_window_modifier_representation(context) return {"FINISHED"} @@ -502,7 +502,7 @@ class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator): props.is_editing = False - update_window_modifier_representation(context, obj) + update_window_modifier_representation(context) pset = tool.Pset.get_element_pset(element, "BBIM_Window") window_data = tool.Ifc.get().createIfcText(json.dumps(window_data, default=list)) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index d6de9a4dac..caf57879e2 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1712,7 +1712,7 @@ class LoadLinkedProject(bpy.types.Operator): mesh.polygons.foreach_set("loop_total", loop_total) mesh.polygons.foreach_set("use_smooth", [0] * total_faces) - if material_ids.size > 0: + if material_ids.size > 0 and len(mesh.polygons) == len(material_ids): mesh.polygons.foreach_set("material_index", material_ids) mesh.update() @@ -2327,7 +2327,7 @@ class MeasureTool(bpy.types.Operator): self.number_is_negative = False self.is_input_on = False self.input_options = ["D", "A", "X", "Y", "Z"] - self.input_type = "OFF" + self.input_type = None self.input_value_xy = [None, None] self.input_panel = {"D": "", "A": "", "X": "", "Y": "", "Z": ""} self.snap_angle = None @@ -2353,9 +2353,9 @@ class MeasureTool(bpy.types.Operator): 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[self.input_type] = self.number_output - + # 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 @@ -2365,7 +2365,7 @@ class MeasureTool(bpy.types.Operator): if event.type == "MOUSEMOVE" or event.type == "INBETWEEN_MOUSEMOVE": self.mousemove_count += 1 self.is_input_on = False - self.input_type = "OFF" + self.input_type = None PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Snap.clear_snapping_ref() tool.Blender.update_viewport() @@ -2382,7 +2382,6 @@ class MeasureTool(bpy.types.Operator): 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) - PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Blender.update_viewport() return {"RUNNING_MODAL"} @@ -2416,7 +2415,15 @@ class MeasureTool(bpy.types.Operator): 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.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() @@ -2424,7 +2431,13 @@ class MeasureTool(bpy.types.Operator): self.recalculate_inputs(context) self.is_input_on = True self.input_type = "D" - self.number_input = [] + + 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() @@ -2436,11 +2449,12 @@ class MeasureTool(bpy.types.Operator): PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Blender.update_viewport() - if event.value == "PRESS" and event.type in {"D", "A"} and not event.shift: + 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() @@ -2464,12 +2478,18 @@ class MeasureTool(bpy.types.Operator): 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"}: + 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.input_type = None self.number_input = [] self.number_output = "" PolylineDecorator.set_input_panel(self.input_panel, self.input_type) @@ -2507,8 +2527,9 @@ class MeasureTool(bpy.types.Operator): 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" + self.input_type = None PolylineDecorator.set_input_panel(self.input_panel, self.input_type) tool.Blender.update_viewport() else: diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index 932b105acf..b00d64f77b 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -78,14 +78,18 @@ class AssignContainer(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Assign Container" bl_description = "Assign current default container to the selected objects" bl_options = {"REGISTER", "UNDO"} + container: bpy.props.IntProperty(options={"SKIP_SAVE"}) def _execute(self, context): props = context.active_object.BIMObjectSpatialProperties - if (container_obj := props.container_obj) and (container := tool.Ifc.get_entity(container_obj)): - for element_obj in context.selected_objects: - core.assign_container( - tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj - ) + if self.container: + container = tool.Ifc.get().by_id(self.container) + elif (container_obj := props.container_obj) and (container := tool.Ifc.get_entity(container_obj)): + pass + for element_obj in context.selected_objects: + core.assign_container( + tool.Ifc, tool.Collector, tool.Spatial, container=container, element_obj=element_obj + ) class EnableEditingContainer(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/spatial/ui.py b/src/bonsai/bonsai/bim/module/spatial/ui.py index 164ab89c01..8340eb2bb5 100644 --- a/src/bonsai/bonsai/bim/module/spatial/ui.py +++ b/src/bonsai/bonsai/bim/module/spatial/ui.py @@ -159,16 +159,18 @@ class BIM_PT_spatial_decomposition(Panel): if not self.props.total_elements: row = self.layout.row(align=True) - row.label(text=f"{self.props.active_container.ifc_class} > No Contained Elements", icon="FILE_3D") + row.label(text=f"{self.props.active_container.ifc_class} > No Elements", icon="FILE_3D") row.prop(self.props, "should_include_children", text="", icon="OUTLINER") + row.operator("bim.assign_container", icon="FOLDER_REDIRECT", text="").container = ifc_definition_id return row = self.layout.row(align=True) row.label( - text=f"{self.props.active_container.ifc_class} > {self.props.total_elements} Contained Elements", + text=f"{self.props.active_container.ifc_class} > {self.props.total_elements} Elements", icon="FILE_3D", ) row.prop(self.props, "should_include_children", text="", icon="OUTLINER") + row.operator("bim.assign_container", icon="FOLDER_REDIRECT", text="").container = ifc_definition_id op = row.operator("bim.select_decomposed_elements", icon="RESTRICT_SELECT_OFF", text="") op.container = ifc_definition_id diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index 05f4d0ff54..b072f0fd67 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -414,8 +414,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): ifc_class="IfcWindowType" if tool.Ifc.get_schema() != "IFC2X3" else "IfcWindowStyle", should_add_representation=False, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_window(obj=obj.name) + with context.temp_override(active_object=obj): + bpy.ops.bim.add_window() elif template == "DOOR": mesh = bpy.data.meshes.new(name) @@ -429,8 +429,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): ifc_class="IfcDoorType" if tool.Ifc.get_schema() != "IFC2X3" else "IfcDoorStyle", should_add_representation=False, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_door(obj=obj.name) + with context.temp_override(active_object=obj): + bpy.ops.bim.add_door() elif template == "STAIR": mesh = bpy.data.meshes.new(name) @@ -444,8 +444,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): ifc_class=ifc_class, should_add_representation=False, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_stair() + with context.temp_override(active_object=obj): + bpy.ops.bim.add_stair() elif template == "RAILING": mesh = bpy.data.meshes.new(name) @@ -460,8 +460,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): should_add_representation=True, context=body, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_railing() + with context.temp_override(active_object=obj): + bpy.ops.bim.add_railing() elif template == "ROOF": mesh = bpy.data.meshes.new(name) @@ -476,8 +476,8 @@ class AddType(bpy.types.Operator, tool.Ifc.Operator): should_add_representation=True, context=body, ) - tool.Blender.select_and_activate_single_object(context, obj) - bpy.ops.bim.add_roof() + with context.temp_override(active_object=obj): + bpy.ops.bim.add_roof() bpy.ops.bim.load_type_thumbnails(ifc_class=ifc_class) props.type_class = props.type_class diff --git a/src/bonsai/bonsai/core/cost.py b/src/bonsai/bonsai/core/cost.py index 30240792a1..7700502a89 100644 --- a/src/bonsai/bonsai/core/cost.py +++ b/src/bonsai/bonsai/core/cost.py @@ -41,8 +41,8 @@ def disable_editing_cost_schedule(cost: tool.Cost) -> None: def remove_cost_schedule(ifc: tool.Ifc, cost: tool.Cost, cost_schedule: ifcopenshell.entity_instance) -> None: - ifc.run("cost.remove_cost_schedule", cost_schedule=cost_schedule) cost.remove_stored_schedule_columns(cost_schedule) + ifc.run("cost.remove_cost_schedule", cost_schedule=cost_schedule) def enable_editing_cost_schedule_attributes(cost: tool.Cost, cost_schedule: ifcopenshell.entity_instance) -> None: @@ -398,3 +398,8 @@ def add_currency(ifc: tool.Ifc, cost: tool.Cost) -> ifcopenshell.entity_instance ifc.run("unit.edit_monetary_unit", unit=unit, attributes=attributes) ifc.run("unit.assign_unit", units=[unit]) return unit + + +def generate_cost_schedule_browser(cost: tool.Cost, cost_schedule: ifcopenshell.entity_instance) -> bpy.types.Panel: + cost_schedule_data = cost.create_cost_schedule_json(cost_schedule) + return cost.generate_cost_schedule_browser(cost_schedule_data) \ No newline at end of file diff --git a/src/bonsai/bonsai/tool/cad.py b/src/bonsai/bonsai/tool/cad.py index 090df007f3..bd735cda98 100644 --- a/src/bonsai/bonsai/tool/cad.py +++ b/src/bonsai/bonsai/tool/cad.py @@ -85,34 +85,39 @@ class Cad: return math.degrees(a) if degrees else a @classmethod - def angle_3_vectors(cls, v1, v2, v3, degrees=False): + def angle_3_vectors(cls, v1, v2, v3, new_angle=None, degrees=False): """ > takes 3 vectors. The order matters, v2 is the center point. - < returns the potentially signed angle as degrees or radians + < returns the signed angle as degrees or radians + < if a new angle is provided, return the rotation vector """ d1 = v1 - v2 - d2 = v2 - v3 + d2 = v3 - v2 - axis = d1.cross(d2) - axis.normalize() - axis = Vector((abs(axis.x), abs(axis.y), abs(axis.z))) + d1.normalize() + d2.normalize() - rotation_axis = d1.cross(d2) + axis = d1.cross(d2).normalized() - # Calculate the unsigned angle between the "from" and "to" vectors + # Calculate the unsigned angle between the "d1" and "d2" vectors a = d1.angle(d2) + # Determine the sign of the angle based on the provided axis - if degrees: - a = math.degrees(a) - - parameter = rotation_axis.dot(axis) - - sign = 1 if parameter <= 0 else -1 - - return a * sign + # If new_angle, determine the direction of the rotation + parameter = round(axis.z, 2) < 0 or (round(axis.y, 2) == 0 and round(axis.x < 0)) or (round(axis.x, 2) == 0 and round(axis.y < 0)) + if new_angle: + rot_mat = Matrix.Rotation(new_angle, 3, axis) + rot_vector = (d1 @ rot_mat) if parameter else (rot_mat @ d1) + return rot_vector else: - return a + sign = -1 if parameter else 1 + + if degrees: + a = math.degrees(a) + return a * sign + else: + return a @classmethod def is_x(cls, value: float, x: float, tolerance: float | None = None) -> bool: diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index 5aeb827ae9..5fa98c32e7 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -811,9 +811,8 @@ class Cost(bonsai.core.tool.Cost): @classmethod def create_cost_schedule_json(cls, cost_schedule: ifcopenshell.entity_instance) -> dict: from bonsai.bim.module.cost.data import CostSchedulesData - if not CostSchedulesData.is_loaded: - CostSchedulesData.load() - cost_items = CostSchedulesData.data["cost_items"] + CostSchedulesData.load() + cost_items = CostSchedulesData.data["cost_items"] data = [] for rel in cost_schedule.Controls or []: for cost_item in rel.RelatedObjects or []: @@ -852,3 +851,9 @@ class Cost(bonsai.core.tool.Cost): unit = tool.Unit.get_project_currency_unit() if unit: return {"id": unit.id(), "name": unit.Currency} + + @classmethod + def generate_cost_schedule_browser(cls, cost_schedule_data: list[dict[str, Any]]) -> None: + if not bpy.context.scene.WebProperties.is_connected: + bpy.ops.bim.connect_websocket_server(page="costing") + tool.Web.send_webui_data(data=cost_schedule_data, data_key="cost_items", event="cost_items") \ No newline at end of file diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py index 64bc3e9e17..0248d4d0ab 100644 --- a/src/bonsai/bonsai/tool/snap.py +++ b/src/bonsai/bonsai/tool/snap.py @@ -524,14 +524,14 @@ class Snap(bonsai.core.tool.Snap): def validate_input(cls, input_number, input_type): grammar_imperial = """ - start: FORMULA? dim expr? + start: (FORMULA dim expr) | dim dim: imperial FORMULA: "=" imperial: feet? "-"? inches? - feet: NUMBER? " "? fraction? "'" - inches: NUMBER? " "? fraction? "\\"" + feet: NUMBER? "-"? fraction? "'" + inches: NUMBER? "-"? fraction? "\\"" fraction: NUMBER "/" NUMBER expr: (ADD | SUB) dim | (MUL | DIV) NUMBER @@ -583,7 +583,10 @@ class Snap(bonsai.core.tool.Snap): def imperial(self, args): if len(args) > 1: - result = args[0] + args[1] + if args[0] <= 0: + result = args[0] - args[1] + else: + result = args[0] + args[1] else: result = args[0] return result diff --git a/src/bonsai/bonsai/tool/web.py b/src/bonsai/bonsai/tool/web.py index 816cae029d..bfc1e8c6dc 100644 --- a/src/bonsai/bonsai/tool/web.py +++ b/src/bonsai/bonsai/tool/web.py @@ -21,6 +21,7 @@ from bonsai.bim.module.web.data import WebData import bonsai.core.tool import bonsai.tool as tool import ifcopenshell.api.sequence +import ifcopenshell.api.cost from typing import Any, Dict, Optional import time import socket @@ -354,17 +355,15 @@ class Web(bonsai.core.tool.Web): if operator_data["type"] == "loadCostSchedule": cost_schedule = ifc_file.by_id(operator_data["costScheduleId"]) bonsai.core.cost.enable_editing_cost_items(tool.Cost, cost_schedule=cost_schedule) - json_data = tool.Cost.create_cost_schedule_json(cost_schedule) - cls.send_webui_data(data=json_data, data_key="cost_items", event="cost_items") - if operator_data["type"] == "addCostItem": - bpy.ops.bim.add_cost_item(cost_item=operator_data["costItemId"]) - cost_schedule = tool.Cost.get_cost_schedule(cost_item=tool.Ifc.get().by_id(operator_data["costItemId"])) - json_data = tool.Cost.create_cost_schedule_json(cost_schedule) - cls.send_webui_data(data=json_data, data_key="cost_items", event="cost_items") + cls.load_cost_schedule_web_ui(cost_schedule) if operator_data["type"] == "selectAssignedElements": 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"] == "addCostItem": + bpy.ops.bim.add_cost_item(cost_item=operator_data["costItemId"]) + 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"] == "editCostItemName": cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) tool.Ifc.run( @@ -373,6 +372,65 @@ class Web(bonsai.core.tool.Web): attributes = {"Name": operator_data["name"]} ) tool.Cost.load_cost_schedule_tree() + if operator_data["type"] == "enableEditingCostValues": + cost_item = tool.Ifc.get().by_id(operator_data["costItemId"]) + cost_values = ifcopenshell.util.cost.get_cost_values(cost_item) + cls.send_webui_data(data={ + "cost_values": cost_values, + "cost_item_id": operator_data["costItemId"] + }, data_key="cost_values", event="cost_values") + if operator_data["type"] == "addCostValue": + value = ifcopenshell.api.cost.add_cost_value( + ifc_file, + parent=ifc_file.by_id(operator_data["costItemId"]), + ) + cls.send_webui_data( + data={ + "cost_value_id" : value.id(), + "cost_item_id":operator_data["costItemId"]}, + data_key="cost_value", + event="cost_value" + ) + if operator_data["type"] == "editCostValues": + cost_item_id = operator_data["costItemId"] + print('Editing cost values', cost_item_id) + print(cost_item_id) + print(type(operator_data["costValues"])) + for value_data in operator_data["costValues"] or []: + print(value_data) + value = ifc_file.by_id(value_data["id"]) + print(value.get_info()) + if value_data["costType"] == "FIXED": + attributes= { + "AppliedValue": value_data["appliedValue"], + } + elif value_data["costType"] == "CATEGORY": + attributes= { + "AppliedValue": value_data["appliedValue"], + "Category": value_data["costCategory"], + } + elif value_data["costType"] == "SUM": + attributes= { + "Category": '*' + } + ifcopenshell.api.cost.edit_cost_value( + file=ifc_file, + cost_value=value, + attributes= attributes + ) + tool.Cost.load_cost_schedule_tree() + cost_item = ifc_file.by_id(operator_data["costItemId"]) + if not cost_item: + print("Cost item not found") + return + cost_schedule = tool.Cost.get_cost_schedule(cost_item=cost_item) + cls.load_cost_schedule_web_ui(cost_schedule) + + + @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=json_data, data_key="cost_items", event="cost_items") @classmethod def handle_gantt_operator(cls, operator_data: dict) -> None: diff --git a/src/bonsai/docs/guides/development/images/unstable-auto-update.png b/src/bonsai/docs/guides/development/images/unstable-auto-update.png new file mode 100644 index 0000000000..7262145b57 Binary files /dev/null and b/src/bonsai/docs/guides/development/images/unstable-auto-update.png differ diff --git a/src/bonsai/docs/guides/development/images/unstable-drag-drop.png b/src/bonsai/docs/guides/development/images/unstable-drag-drop.png new file mode 100644 index 0000000000..efe9ca4d56 Binary files /dev/null and b/src/bonsai/docs/guides/development/images/unstable-drag-drop.png differ diff --git a/src/bonsai/docs/guides/development/images/unstable-icon.png b/src/bonsai/docs/guides/development/images/unstable-icon.png new file mode 100644 index 0000000000..b92fc7d463 Binary files /dev/null and b/src/bonsai/docs/guides/development/images/unstable-icon.png differ diff --git a/src/bonsai/docs/guides/development/images/unstable-repo.png b/src/bonsai/docs/guides/development/images/unstable-repo.png new file mode 100644 index 0000000000..d62e867dcd Binary files /dev/null and b/src/bonsai/docs/guides/development/images/unstable-repo.png differ diff --git a/src/bonsai/docs/guides/development/images/unstable-restart.png b/src/bonsai/docs/guides/development/images/unstable-restart.png new file mode 100644 index 0000000000..cb4f13a46b Binary files /dev/null and b/src/bonsai/docs/guides/development/images/unstable-restart.png differ diff --git a/src/bonsai/docs/guides/development/index.rst b/src/bonsai/docs/guides/development/index.rst index e25659178b..d0a0e46bf5 100644 --- a/src/bonsai/docs/guides/development/index.rst +++ b/src/bonsai/docs/guides/development/index.rst @@ -9,6 +9,7 @@ This chapter covers how you can help contribute to Bonsai. :hidden: :maxdepth: 2 + installation hello_world running_tests translations diff --git a/src/bonsai/docs/guides/development/installation.rst b/src/bonsai/docs/guides/development/installation.rst index 8cddf8c1e3..e59a954ef3 100644 --- a/src/bonsai/docs/guides/development/installation.rst +++ b/src/bonsai/docs/guides/development/installation.rst @@ -3,20 +3,16 @@ Installation There are different methods of installation, depending on your situation. -1. **Unstable installation** is recommended for power users helping with testing. -2. **Bundling for Blender** is recommended for distributing the add-on. -3. **Live development environment** is recommended for developers who are actively coding. -4. **Packaged installation** is recommended for those who use a package manager. +1. :ref:`guides/development/installation:Unstable installation` is recommended + for power users helping with testing. +2. :ref:`guides/development/installation:Bundling for blender` is recommended for distributing the add-on. +3. :ref:`guides/development/installation:Live development environment` is + recommended for developers who are actively coding. +4. :ref:`guides/development/installation:Packaged installation` is recommended + for those who use a package manager. -Unstable installation ---------------------- - -**Unstable installation** is almost the same as **Stable installation**, except -that they are typically updated every day. Simply download a daily build from -the `GitHub releases page -`__, -then follow the usual :doc:`installation -instructions`. +System requirements +------------------- Bonsai officially supports all major 64-bit platforms, as well as the Python version shipped by the Blender Foundation for the most recent three major @@ -41,6 +37,76 @@ Other system specifications match the `Blender Requirements Sometimes, a build may be delayed, or contain broken code. We try to avoid this, but it happens. +Unstable installation +--------------------- + +**Unstable installation** is almost the same as **Stable installation**, except +that they are typically updated every day. To install the **Unstable** version: + +1. Open up Blender, and click on ``Edit > Preferences``. + + .. image:: /quickstart/images/install-bonsai-1.png + +2. Select the **Get Extensions** tab, and press **Allow Online Access**. + + .. image:: /quickstart/images/install-bonsai-2.png + +3. Go to the `Bonsai Unstable Repository + `__, and drag and drop + from the appropriate link in the ``ID`` column of the table into Blender + depending on your operating system. + + .. image:: images/unstable-drag-drop.png + +4. Enable **Check for Updates on Startup** to get updates for daily Bonsai + builds automatically. + + .. image:: images/unstable-auto-update.png + +.. tip:: + + Instead of drag and drop, you can manually create the repository: + + Open :menuselection:`Topbar --> Edit --> Preferences --> Get Extensions + --> Repositories (Top Right) --> "+" Icon --> Add Remote Remository`. + You'll see a window similar to the one above. + + Use as URL: + ``https://raw.githubusercontent.com/IfcOpenShell/bonsai_unstable_repo/main/index.json`` + and enable **Check for Updates on Startup** if you want them. + +5. Search for **Bonsai** in the top left search bar, then press the **Install** + button. + + .. image:: /quickstart/images/install-bonsai-3.png + +.. warning:: + + Make sure the extension you install has ``raw.githubusercontent.com`` as + it's "Repository" (not ``extensions.blender.org``). + + .. image:: images/unstable-repo.png + +6. Whenever a new update is available, you'll see it in the bottom right + :menuselection:`Status Bar` + + .. image:: images/unstable-icon.png + +7. To update, click on the update button in :menuselection:`Topbar --> Edit --> + Preferences --> Get Extensions`. + + .. image:: /guides/images/update.png + +8. After an update, be sure to restart. + + .. image:: images/unstable-restart.png + +If you wish to install an **Unstable** version offline, you can download a +daily build from the `GitHub releases page +`__, +then go to :menuselection:`Topbar --> Edit --> Preferences --> Get Extensions +--> "V" Icon (top right) --> Install from Disk`. + Bundling for Blender -------------------- @@ -72,12 +138,13 @@ Live development environment ---------------------------- One option for developers who want to actively develop from source is to follow -the instructions from :ref:`devs/installation:Bundling for Blender`. However, +the instructions from :ref:`guides/development/installation:Bundling for Blender`. However, creating a build, uninstalling the old add-on, and installing a new build is a slow process. Although it works, it is very slow, so we do not recommend it. -A more rapid approach is to follow the :ref:`devs/installation:Unstable -installation` method, as this provides all dependencies for you out of the box. +A more rapid approach is to follow the +:ref:`guides/development/installation:Unstable installation` method, as this +provides all dependencies for you out of the box. Once you've done this, you can replace certain Python files that tend to be updated frequently with those from the Git repository. We're going to use diff --git a/src/bonsai/docs/guides/troubleshooting.rst b/src/bonsai/docs/guides/troubleshooting.rst index f8f0aa9290..9a87577b65 100644 --- a/src/bonsai/docs/guides/troubleshooting.rst +++ b/src/bonsai/docs/guides/troubleshooting.rst @@ -30,13 +30,13 @@ Updating We always recommend to use the latest version. -Open up Blender, click on ``Edit > Preferences``, and select the **Get -Extensions** tab. If an update is available, you will see a button next to the +Open up Blender, click on :menuselection:`Topbar --> Edit --> Preferences --> +Get Extensions`. If an update is available, you will see a button next to the **Bonsai** add-on. Updates are typically available every 2 months. If you need something more -frequent, check out :ref:`devs/installation:unstable installation` which is -updated every day. +frequent, check out :ref:`guides/development/installation:Unstable +installation` which is updated every day. .. image:: images/update.png @@ -94,7 +94,8 @@ Blender geometry that represents what the model might've looked at at some point. At worst, you might be looking at a completely wrong model. If you continue to open and save ``.blend`` files, you will run the risk of -editing something that doesn't actually exist in your IFC model. This will +editing something that doesn't actually exist in your IFC model (e.g you can +meet an error similar to "RuntimeError: Instance #1234 not found"). This will create unpredictable, and sometimes unrecoverable errors. To avoid this issue, only open and save IFCs. diff --git a/src/bonsai/test/bim/feature/light.feature b/src/bonsai/test/bim/feature/light.feature index 86dbdbf633..831d6f25bb 100644 --- a/src/bonsai/test/bim/feature/light.feature +++ b/src/bonsai/test/bim/feature/light.feature @@ -5,7 +5,6 @@ Scenario: Viewing default solar settings Given an empty IFC project And I look at the "Solar Access / Shadow" panel Then I see "Etc/GMT" - And I see "Sunrise: 06:02:50" Scenario: Changing the month Given an empty IFC project @@ -33,7 +32,7 @@ Scenario: Automatic timezone detection based on lat / long When I set the "Latitude" property to "10.0" And I set the "Longitude" property to "20.0" Then I see "Africa/Ndjamena" - And I see "Sunrise: 05:29:43" + And I see "Sunrise: 05:56:42" Scenario: Display the sun path Given an empty IFC project diff --git a/src/bonsai/test/bim/feature/misc.feature b/src/bonsai/test/bim/feature/misc.feature index b4c76c3401..d418ee3ea5 100644 --- a/src/bonsai/test/bim/feature/misc.feature +++ b/src/bonsai/test/bim/feature/misc.feature @@ -27,7 +27,7 @@ Scenario: Resize to storey And the object "IfcWall/Cube" is selected And the variable "storey" is "tool.Ifc.get().by_type('IfcBuildingStorey')[0].id()" And I press "bim.set_default_container(container={storey})" - And I press "bim.assign_container()" + And I press "bim.assign_container(container={storey})" When I press "bim.resize_to_storey(total_storeys=1)" Then nothing happens diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 9dcbebc906..7cfadb02dd 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-7e6607a-$(PLATFORM).zip -IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v0.7.11-7e6607a-$(PLATFORM).zip +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 .PHONY: test test: diff --git a/src/ifcopenshell-python/ifcopenshell/util/cost.py b/src/ifcopenshell-python/ifcopenshell/util/cost.py index d9ce6f874f..1844c76eb0 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/cost.py +++ b/src/ifcopenshell-python/ifcopenshell/util/cost.py @@ -246,6 +246,26 @@ def get_cost_item_assignments( ] +def get_cost_values(cost_item: ifcopenshell.entity_instance) -> list[dict[str, str]]: + results = [] + for cost_value in cost_item.CostValues or []: + label = "{0:.2f}".format(calculate_applied_value(cost_item, cost_value)) + label += " = {}".format(serialise_cost_value(cost_value)) + results.append( + { + "id": cost_value.id(), + "label": label, + "name": cost_value.Name, + "category": cost_value.Category, + "applied_value": ( + get_primitive_applied_value(cost_value.AppliedValue) if cost_value.AppliedValue else None + ), + } + ) + print(results) + return results + + class CostValueUnserialiser: def parse(self, formula: str): l = lark.Lark( diff --git a/src/ifcparse/IfcSpfHeader.h b/src/ifcparse/IfcSpfHeader.h index f07faeacf9..f71dacfa68 100644 --- a/src/ifcparse/IfcSpfHeader.h +++ b/src/ifcparse/IfcSpfHeader.h @@ -52,6 +52,10 @@ class IFC_PARSE_API HeaderEntity { return data_.size(); } + AttributeValue getArgument(size_t index) const { + return data_.get_attribute_value(index); + } + std::string toString(bool upper = false) const { std::stringstream stream; stream << datatype_; diff --git a/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py b/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py index 3b7f49de9c..f9fdc9727a 100644 --- a/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py +++ b/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py @@ -136,21 +136,21 @@ class Patcher: if material.is_a("IfcMaterial"): materials = [] elif material.is_a("IfcMaterialLayerSet"): - for idx, item in enumerate(material.MaterialLayers): + for idx, item in enumerate(material.MaterialLayers or []): material = item.Material properties.append([i, "IFC Material", f"Layer {idx + 1} Name", getattr(item, "Name", None)]) properties.append([i, "IFC Material", f"Layer {idx + 1} Material", material.Name]) if category := getattr(material, "Category", None): properties.append([i, "IFC Material", f"Layer {idx + 1} Category", category]) elif material.is_a("IfcMaterialProfileSet"): - for idx, item in enumerate(material.MaterialProfiles): + for idx, item in enumerate(material.MaterialProfiles or []): material = item.Material properties.append([i, "IFC Material", f"Profile {idx + 1} Name", item.Name]) properties.append([i, "IFC Material", f"Profile {idx + 1} Material", material.Name]) if category := getattr(material, "Category", None): properties.append([i, "IFC Material", f"Profile {idx + 1} Category", category]) elif material.is_a("IfcMaterialConstituentSet"): - for idx, item in enumerate(material.MaterialConstituents): + for idx, item in enumerate(material.MaterialConstituents or []): material = item.Material properties.append([i, "IFC Material", f"Constituent {idx + 1} Name", item.Name]) properties.append([i, "IFC Material", f"Constituent {idx + 1} Material", material.Name]) diff --git a/src/ifcwrap/IfcParseWrapper.i b/src/ifcwrap/IfcParseWrapper.i index 3a30620670..117041a6e7 100644 --- a/src/ifcwrap/IfcParseWrapper.i +++ b/src/ifcwrap/IfcParseWrapper.i @@ -520,6 +520,26 @@ static IfcUtil::ArgumentType helper_fn_attribute_type(const IfcUtil::IfcBaseClas } } +// Expose FileDescription and FileName header entities +// to make them readable even if they were not filled properly before. +// Though it is invalid IFC, technically. +// FileSchema is not exposed as IFC file won't load if it's invalid. + +%extend IfcParse::FileDescription { + AttributeValue description() const { return $self->getArgument(0); } + AttributeValue implementation_level() const { return $self->getArgument(1); } +}; + +%extend IfcParse::FileName { + AttributeValue name() const { return $self->getArgument(0); } + AttributeValue time_stamp() const { return $self->getArgument(1); } + AttributeValue author() const { return $self->getArgument(2); } + AttributeValue organization() const { return $self->getArgument(3); } + AttributeValue preprocessor_version() const { return $self->getArgument(4); } + AttributeValue originating_system() const { return $self->getArgument(5); } + AttributeValue authorization() const { return $self->getArgument(6); } +}; + %extend IfcParse::IfcSpfHeader { %pythoncode %{ # Hide the getters with read-only property implementations