From 8ffd9dc1f9835e2eb3182c26fd0b7c6be26d4db0 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Mon, 14 Mar 2022 09:54:28 +0100 Subject: [PATCH 01/85] #2086 surface_area and volume --- src/ifcwrap/IfcGeomWrapper.i | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index 1957b038e1..dba32a5a70 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -300,6 +300,33 @@ struct ShapeRTTI : public boost::static_visitor %} }; +%extend IfcGeom::BRepElement { + double calc_volume_() const { + double v; + if ($self->geometry().calculate_volume(v)) { + return v; + } else { + return std::numeric_limits::quiet_NaN(); + } + } + + double calc_surface_area_() const { + double v; + if ($self->geometry().calculate_surface_area(v)) { + return v; + } else { + return std::numeric_limits::quiet_NaN(); + } + } + + %pythoncode %{ + # Hide the getters with read-only property implementations + geometry = property(geometry) + volume = property(calc_volume_) + surface_area = property(calc_surface_area_) + %} +}; + %extend IfcGeom::Material { %pythoncode %{ # Hide the getters with read-only property implementations From bc9e403a5812c594908aa29e5f26559fd474a704 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 14 Mar 2022 20:07:39 +1100 Subject: [PATCH 02/85] #2088. Minor fix. Sorry more stuff I forgot to commit. --- src/blenderbim/blenderbim/core/tool.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index 0ba921fbfa..4a6ce898eb 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -133,6 +133,22 @@ class Debug: def purge_hdf5_cache(cls): pass +@interface +class Document: + def disable_document_assignment_ui(cls, obj): pass + def disable_editing_document(cls): pass + def disable_editing_ui(cls): pass + def enable_document_assignment_ui(cls, obj): pass + def enable_information_editing_ui(cls): pass + def enable_reference_editing_ui(cls): pass + def export_document_attributes(cls): pass + def import_document_attributes(cls, document): pass + def import_information(cls): pass + def import_references(cls): pass + def is_document_information(cls, document): pass + def set_active_document(cls, document): pass + + @interface class Drawing: def create_annotation_object(cls, object_type): pass From e343494ab4f93f3b8e14ee876bd8a20f425f09b2 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 14 Mar 2022 20:39:11 +1100 Subject: [PATCH 03/85] Document information is now always assigned to the project by default. --- .../blenderbim/bim/module/document/data.py | 8 ++++- .../api/document/add_information.py | 16 +++++++-- .../test/api/document/test_add_information.py | 35 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 src/ifcopenshell-python/test/api/document/test_add_information.py diff --git a/src/blenderbim/blenderbim/bim/module/document/data.py b/src/blenderbim/blenderbim/bim/module/document/data.py index 3718cb46a5..fa64c1ce05 100644 --- a/src/blenderbim/blenderbim/bim/module/document/data.py +++ b/src/blenderbim/blenderbim/bim/module/document/data.py @@ -41,7 +41,13 @@ class DocumentData: @classmethod def total_information(cls): - return len(tool.Ifc.get().by_type("IfcDocumentInformation")) + return len( + [ + rel + for rel in tool.Ifc.get().by_type("IfcProject")[0].HasAssociations or [] + if rel.is_a("IfcRelAssociatesDocument") and rel.RelatingDocument.is_a("IfcDocumentInformation") + ] + ) @classmethod def total_references(cls): diff --git a/src/ifcopenshell-python/ifcopenshell/api/document/add_information.py b/src/ifcopenshell-python/ifcopenshell/api/document/add_information.py index f4a6cab8ef..c23ec11db0 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/document/add_information.py +++ b/src/ifcopenshell-python/ifcopenshell/api/document/add_information.py @@ -22,12 +22,24 @@ import ifcopenshell class Usecase: def __init__(self, file, **settings): self.file = file - self.settings = {} + self.settings = {"parent": None} for key, value in settings.items(): self.settings[key] = value def execute(self): id_attribute = "DocumentId" if self.file.schema == "IFC2X3" else "Identification" - return self.file.create_entity( + information = self.file.create_entity( "IfcDocumentInformation", **{id_attribute: ifcopenshell.guid.new(), "Name": "Unnamed"} ) + parent = self.settings["parent"] + if not parent and self.file.by_type("IfcProject"): + parent = self.file.by_type("IfcProject")[0] + if parent.is_a("IfcProject") or prarent.is_a("IfcContext"): + self.file.create_entity( + "IfcRelAssociatesDocument", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=ifcopenshell.api.run("owner.create_owner_history", self.file), + RelatingDocument=information, + RelatedObjects=[parent], + ) + return information diff --git a/src/ifcopenshell-python/test/api/document/test_add_information.py b/src/ifcopenshell-python/test/api/document/test_add_information.py new file mode 100644 index 0000000000..1499449348 --- /dev/null +++ b/src/ifcopenshell-python/test/api/document/test_add_information.py @@ -0,0 +1,35 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import test.bootstrap +import ifcopenshell.api + + +class TestAddInformation(test.bootstrap.IFC4): + def test_adding_information(self): + self.file.createIfcProject() + element = ifcopenshell.api.run("document.add_information", self.file, parent=None) + assert element.is_a("IfcDocumentInformation") + assert len(self.file.by_type("IfcDocumentInformation")) == 1 + + def test_adding_information_to_the_project(self): + project = self.file.createIfcProject() + element = ifcopenshell.api.run("document.add_information", self.file, parent=None) + rel = element.DocumentInfoForObjects[0] + assert rel.is_a("IfcRelAssociatesDocument") + assert rel.RelatedObjects[0] == project From 9cc683ea6ebc45562619105e9a468a6dbca86368 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 14 Mar 2022 20:39:39 +1100 Subject: [PATCH 04/85] Fix bug where ownership history was not tracked on document association. --- .../ifcopenshell/api/document/assign_document.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/document/assign_document.py b/src/ifcopenshell-python/ifcopenshell/api/document/assign_document.py index ff1f416eb5..c43c2afd3b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/document/assign_document.py +++ b/src/ifcopenshell-python/ifcopenshell/api/document/assign_document.py @@ -56,7 +56,7 @@ class Usecase: "IfcRelAssociatesDocument", **{ "GlobalId": ifcopenshell.guid.new(), - # TODO: owner history + "OwnerHistory": ifcopenshell.api.run("owner.create_owner_history", self.file), "RelatingDocument": self.settings["document"], } ) From 6fb1e34ab4927a57639ebc6713c91b4be7216c25 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 15 Mar 2022 14:41:47 +1100 Subject: [PATCH 05/85] You can now add IFC subdocuments or supersede documents --- .../ifcopenshell/api/document/add_information.py | 16 ++++++++++++++-- .../test/api/document/test_add_information.py | 14 +++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/document/add_information.py b/src/ifcopenshell-python/ifcopenshell/api/document/add_information.py index c23ec11db0..d54cede7dc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/document/add_information.py +++ b/src/ifcopenshell-python/ifcopenshell/api/document/add_information.py @@ -29,12 +29,12 @@ class Usecase: def execute(self): id_attribute = "DocumentId" if self.file.schema == "IFC2X3" else "Identification" information = self.file.create_entity( - "IfcDocumentInformation", **{id_attribute: ifcopenshell.guid.new(), "Name": "Unnamed"} + "IfcDocumentInformation", **{id_attribute: "X", "Name": "Unnamed"} ) parent = self.settings["parent"] if not parent and self.file.by_type("IfcProject"): parent = self.file.by_type("IfcProject")[0] - if parent.is_a("IfcProject") or prarent.is_a("IfcContext"): + if parent.is_a("IfcProject") or parent.is_a("IfcContext"): self.file.create_entity( "IfcRelAssociatesDocument", GlobalId=ifcopenshell.guid.new(), @@ -42,4 +42,16 @@ class Usecase: RelatingDocument=information, RelatedObjects=[parent], ) + elif parent.is_a("IfcDocumentInformation"): + if parent.IsPointer: + rel = parent.IsPointer[0] + documents = set(rel.RelatedDocuments) + documents.add(information) + rel.RelatedDocuments = list(documents) + else: + self.file.create_entity( + "IfcDocumentInformationRelationship", + RelatingDocument=parent, + RelatedDocuments=[information] + ) return information diff --git a/src/ifcopenshell-python/test/api/document/test_add_information.py b/src/ifcopenshell-python/test/api/document/test_add_information.py index 1499449348..4d225e1f39 100644 --- a/src/ifcopenshell-python/test/api/document/test_add_information.py +++ b/src/ifcopenshell-python/test/api/document/test_add_information.py @@ -22,7 +22,7 @@ import ifcopenshell.api class TestAddInformation(test.bootstrap.IFC4): def test_adding_information(self): - self.file.createIfcProject() + project = self.file.createIfcProject() element = ifcopenshell.api.run("document.add_information", self.file, parent=None) assert element.is_a("IfcDocumentInformation") assert len(self.file.by_type("IfcDocumentInformation")) == 1 @@ -33,3 +33,15 @@ class TestAddInformation(test.bootstrap.IFC4): rel = element.DocumentInfoForObjects[0] assert rel.is_a("IfcRelAssociatesDocument") assert rel.RelatedObjects[0] == project + + def test_adding_a_subdocument(self): + project = self.file.createIfcProject() + parent = ifcopenshell.api.run("document.add_information", self.file, parent=None) + element = ifcopenshell.api.run("document.add_information", self.file, parent=parent) + assert element.is_a("IfcDocumentInformation") + assert len(self.file.by_type("IfcDocumentInformation")) == 2 + assert element.IsPointedTo[0].RelatingDocument == parent + assert parent.IsPointer[0].RelatedDocuments[0] == element + element2 = ifcopenshell.api.run("document.add_information", self.file, parent=parent) + assert element in parent.IsPointer[0].RelatedDocuments + assert element2 in parent.IsPointer[0].RelatedDocuments From db85aa90d3c7749fc0a9d43470d75488150e405f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 15 Mar 2022 14:43:24 +1100 Subject: [PATCH 06/85] You can now add document references that are part of a document information --- .../api/document/add_reference.py | 9 ++--- .../test/api/document/test_add_reference_.py | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 src/ifcopenshell-python/test/api/document/test_add_reference_.py diff --git a/src/ifcopenshell-python/ifcopenshell/api/document/add_reference.py b/src/ifcopenshell-python/ifcopenshell/api/document/add_reference.py index 4c02981743..824fb834ad 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/document/add_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/document/add_reference.py @@ -16,16 +16,17 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . -import ifcopenshell - class Usecase: def __init__(self, file, **settings): self.file = file - self.settings = {} + self.settings = {"information": None} for key, value in settings.items(): self.settings[key] = value def execute(self): id_attribute = "ItemReference" if self.file.schema == "IFC2X3" else "Identification" - return self.file.create_entity("IfcDocumentReference", **{id_attribute: ifcopenshell.guid.new()}) + attributes = {id_attribute: "X"} + if self.file.schema != "IFC2X3": + attributes["ReferencedDocument"] = self.settings["information"] + return self.file.create_entity("IfcDocumentReference", **attributes) diff --git a/src/ifcopenshell-python/test/api/document/test_add_reference_.py b/src/ifcopenshell-python/test/api/document/test_add_reference_.py new file mode 100644 index 0000000000..584bbf44a6 --- /dev/null +++ b/src/ifcopenshell-python/test/api/document/test_add_reference_.py @@ -0,0 +1,35 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import test.bootstrap +import ifcopenshell.api + + +class TestAddReference(test.bootstrap.IFC4): + def test_adding_a_reference(self): + element = ifcopenshell.api.run("document.add_reference", self.file, information=None) + assert element.is_a("IfcDocumentReference") + assert len(self.file.by_type("IfcDocumentReference")) == 1 + + def test_adding_a_reference_to_an_information(self): + self.file.createIfcProject() + information = ifcopenshell.api.run("document.add_information", self.file, parent=None) + element = ifcopenshell.api.run("document.add_reference", self.file, information=information) + assert element.is_a("IfcDocumentReference") + assert len(self.file.by_type("IfcDocumentReference")) == 1 + assert element.ReferencedDocument == information From 233e73f9f61351f429008ad7c989fdf8b429fe65 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 15 Mar 2022 14:45:18 +1100 Subject: [PATCH 07/85] Removing documents now removes all subdocuments recursively and references --- .../api/document/remove_information.py | 39 +++++++++++++++ ...remove_document.py => remove_reference.py} | 11 ++-- .../api/document/test_remove_information.py | 50 +++++++++++++++++++ .../api/document/test_remove_reference_.py | 43 ++++++++++++++++ 4 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 src/ifcopenshell-python/ifcopenshell/api/document/remove_information.py rename src/ifcopenshell-python/ifcopenshell/api/document/{remove_document.py => remove_reference.py} (76%) create mode 100644 src/ifcopenshell-python/test/api/document/test_remove_information.py create mode 100644 src/ifcopenshell-python/test/api/document/test_remove_reference_.py diff --git a/src/ifcopenshell-python/ifcopenshell/api/document/remove_information.py b/src/ifcopenshell-python/ifcopenshell/api/document/remove_information.py new file mode 100644 index 0000000000..11a0fc6cd4 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/document/remove_information.py @@ -0,0 +1,39 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + + +import ifcopenshell.api + + +class Usecase: + def __init__(self, file, **settings): + self.file = file + self.settings = {"information": None} + for key, value in settings.items(): + self.settings[key] = value + + def execute(self): + for reference in self.settings["information"].HasDocumentReferences or []: + ifcopenshell.api.run("document.remove_reference", self.file, reference=reference) + for rel in self.settings["information"].IsPointer or []: + for information in rel.RelatedDocuments: + ifcopenshell.api.run("document.remove_information", self.file, information=information) + self.file.remove(rel) + for rel in self.settings["information"].DocumentInfoForObjects or []: + self.file.remove(rel) + self.file.remove(self.settings["information"]) diff --git a/src/ifcopenshell-python/ifcopenshell/api/document/remove_document.py b/src/ifcopenshell-python/ifcopenshell/api/document/remove_reference.py similarity index 76% rename from src/ifcopenshell-python/ifcopenshell/api/document/remove_document.py rename to src/ifcopenshell-python/ifcopenshell/api/document/remove_reference.py index b83e9fb560..f4cc669912 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/document/remove_document.py +++ b/src/ifcopenshell-python/ifcopenshell/api/document/remove_reference.py @@ -1,5 +1,5 @@ # IfcOpenShell - IFC toolkit and geometry engine -# Copyright (C) 2021 Dion Moult +# Copyright (C) 2022 Dion Moult # # This file is part of IfcOpenShell. # @@ -20,12 +20,11 @@ class Usecase: def __init__(self, file, **settings): self.file = file - self.settings = {"document": None} + self.settings = {"reference": None} for key, value in settings.items(): self.settings[key] = value def execute(self): - self.file.remove(self.settings["document"]) - for rel in self.file.by_type("IfcRelAssociatesDocument"): - if not rel.RelatingDocument: - self.file.remove(rel) + for rel in self.settings["reference"].DocumentRefForObjects or []: + self.file.remove(rel) + self.file.remove(self.settings["reference"]) diff --git a/src/ifcopenshell-python/test/api/document/test_remove_information.py b/src/ifcopenshell-python/test/api/document/test_remove_information.py new file mode 100644 index 0000000000..4df4857052 --- /dev/null +++ b/src/ifcopenshell-python/test/api/document/test_remove_information.py @@ -0,0 +1,50 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import test.bootstrap +import ifcopenshell.api + + +class TestRemoveInformation(test.bootstrap.IFC4): + def test_remove_information(self): + project = self.file.createIfcProject() + element = ifcopenshell.api.run("document.add_information", self.file, parent=None) + ifcopenshell.api.run("document.remove_information", self.file, information=element) + assert len(self.file.by_type("IfcDocumentInformation")) == 0 + assert len(self.file.by_type("IfcRelAssociatesDocument")) == 0 + + def test_removing_all_references_of_an_information(self): + project = self.file.createIfcProject() + information = ifcopenshell.api.run("document.add_information", self.file, parent=None) + ifcopenshell.api.run("document.add_reference", self.file, information=information) + ifcopenshell.api.run("document.remove_information", self.file, information=information) + assert len(self.file.by_type("IfcDocumentInformation")) == 0 + assert len(self.file.by_type("IfcDocumentReference")) == 0 + assert len(self.file.by_type("IfcRelAssociatesDocument")) == 0 + + def test_removing_all_subdocuments_and_their_references_too(self): + project = self.file.createIfcProject() + information = ifcopenshell.api.run("document.add_information", self.file, parent=None) + information2 = ifcopenshell.api.run("document.add_information", self.file, parent=information) + ifcopenshell.api.run("document.add_reference", self.file, information=information) + ifcopenshell.api.run("document.add_reference", self.file, information=information2) + ifcopenshell.api.run("document.remove_information", self.file, information=information) + assert len(self.file.by_type("IfcDocumentInformation")) == 0 + assert len(self.file.by_type("IfcDocumentReference")) == 0 + assert len(self.file.by_type("IfcRelAssociatesDocument")) == 0 + assert len(self.file.by_type("IfcDocumentInformationRelationship")) == 0 diff --git a/src/ifcopenshell-python/test/api/document/test_remove_reference_.py b/src/ifcopenshell-python/test/api/document/test_remove_reference_.py new file mode 100644 index 0000000000..e5f5892172 --- /dev/null +++ b/src/ifcopenshell-python/test/api/document/test_remove_reference_.py @@ -0,0 +1,43 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import test.bootstrap +import ifcopenshell.api + + +class TestRemoveReference(test.bootstrap.IFC4): + def test_removing_reference(self): + project = self.file.createIfcProject() + information = ifcopenshell.api.run("document.add_information", self.file, parent=None) + reference = ifcopenshell.api.run("document.add_reference", self.file, information=information) + ifcopenshell.api.run("document.remove_reference", self.file, reference=reference) + assert len(self.file.by_type("IfcDocumentReference")) == 0 + assert len(self.file.by_type("IfcDocumentInformation")) == 1 + assert len(self.file.by_type("IfcRelAssociatesDocument")) == 1 + + def test_removing_a_reference_assigned_to_an_object(self): + project = self.file.createIfcProject() + wall = self.file.createIfcWall() + information = ifcopenshell.api.run("document.add_information", self.file, parent=None) + reference = ifcopenshell.api.run("document.add_reference", self.file, information=information) + ifcopenshell.api.run("document.assign_document", self.file, product=wall, document=reference) + assert len(self.file.by_type("IfcRelAssociatesDocument")) == 2 + ifcopenshell.api.run("document.remove_reference", self.file, reference=reference) + assert len(self.file.by_type("IfcDocumentReference")) == 0 + assert len(self.file.by_type("IfcDocumentInformation")) == 1 + assert len(self.file.by_type("IfcRelAssociatesDocument")) == 1 From 38b708ff1fdf79edfd688a38b9eff8579d001fb7 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 15 Mar 2022 14:48:59 +1100 Subject: [PATCH 08/85] Redesigned document UI to handle hierarchical documents and make it easier to reference --- .../bim/module/document/__init__.py | 24 ++-- .../blenderbim/bim/module/document/data.py | 12 +- .../bim/module/document/operator.py | 64 +++------ .../blenderbim/bim/module/document/prop.py | 11 +- .../blenderbim/bim/module/document/ui.py | 123 +++++++++--------- src/blenderbim/blenderbim/core/document.py | 103 ++++++++------- src/blenderbim/blenderbim/core/tool.py | 15 ++- src/blenderbim/blenderbim/tool/document.py | 99 +++++++++----- src/blenderbim/pytest.ini | 1 + .../test/bim/feature/document.feature | 112 ++++++++++++++++ src/blenderbim/test/core/test_document.py | 105 ++++++++------- src/blenderbim/test/tool/test_document.py | 112 +++++++++++----- 12 files changed, 485 insertions(+), 296 deletions(-) create mode 100644 src/blenderbim/test/bim/feature/document.feature diff --git a/src/blenderbim/blenderbim/bim/module/document/__init__.py b/src/blenderbim/blenderbim/bim/module/document/__init__.py index edfe7a60ef..93dbf703b2 100644 --- a/src/blenderbim/blenderbim/bim/module/document/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/document/__init__.py @@ -20,35 +20,29 @@ import bpy from . import ui, prop, operator classes = ( - operator.LoadInformation, - operator.LoadDocumentReferences, - operator.DisableDocumentEditingUI, - operator.EnableEditingDocument, - operator.DisableEditingDocument, - operator.AddInformation, operator.AddDocumentReference, - operator.EditInformation, - operator.EditDocumentReference, - operator.RemoveDocument, - operator.EnableAssigningDocument, - operator.DisableAssigningDocument, + operator.AddInformation, operator.AssignDocument, + operator.DisableDocumentEditingUI, + operator.DisableEditingDocument, + operator.EditDocument, + operator.EnableEditingDocument, + operator.LoadDocument, + operator.LoadParentDocument, + operator.LoadProjectDocuments, + operator.RemoveDocument, operator.UnassignDocument, prop.Document, prop.BIMDocumentProperties, - prop.BIMObjectDocumentProperties, ui.BIM_PT_documents, ui.BIM_PT_object_documents, ui.BIM_UL_documents, - ui.BIM_UL_object_documents, ) def register(): bpy.types.Scene.BIMDocumentProperties = bpy.props.PointerProperty(type=prop.BIMDocumentProperties) - bpy.types.Object.BIMObjectDocumentProperties = bpy.props.PointerProperty(type=prop.BIMObjectDocumentProperties) def unregister(): del bpy.types.Scene.BIMDocumentProperties - del bpy.types.Object.BIMObjectDocumentProperties diff --git a/src/blenderbim/blenderbim/bim/module/document/data.py b/src/blenderbim/blenderbim/bim/module/document/data.py index fa64c1ce05..bd15c610ac 100644 --- a/src/blenderbim/blenderbim/bim/module/document/data.py +++ b/src/blenderbim/blenderbim/bim/module/document/data.py @@ -35,7 +35,7 @@ class DocumentData: def load(cls): cls.data = { "total_information": cls.total_information(), - "total_references": cls.total_references(), + "parent_document": cls.parent_document(), } cls.is_loaded = True @@ -50,8 +50,14 @@ class DocumentData: ) @classmethod - def total_references(cls): - return len(tool.Ifc.get().by_type("IfcDocumentReference")) + def parent_document(cls): + props = bpy.context.scene.BIMDocumentProperties + if len(props.breadcrumbs): + parent = tool.Ifc.get().by_id(int(props.breadcrumbs[-1].name)) + if tool.Ifc.get_schema() == "IFC2X3": + return str(parent.DocumentId) + return str(parent.Identification) + return "" class ObjectDocumentData: diff --git a/src/blenderbim/blenderbim/bim/module/document/operator.py b/src/blenderbim/blenderbim/bim/module/document/operator.py index d97070a38d..32777cb314 100644 --- a/src/blenderbim/blenderbim/bim/module/document/operator.py +++ b/src/blenderbim/blenderbim/bim/module/document/operator.py @@ -26,22 +26,32 @@ from blenderbim.bim.ifc import IfcStore from ifcopenshell.api.document.data import Data -class LoadInformation(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.load_information" - bl_label = "Load Information" +class LoadProjectDocuments(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.load_project_documents" + bl_label = "Load Project Documents" bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - core.load_information(tool.Document) + core.load_project_documents(tool.Document) -class LoadDocumentReferences(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.load_document_references" - bl_label = "Load Document References" +class LoadDocument(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.load_document" + bl_label = "Load Document" + bl_options = {"REGISTER", "UNDO"} + document: bpy.props.IntProperty() + + def _execute(self, context): + core.load_document(tool.Document, document=tool.Ifc.get().by_id(self.document)) + + +class LoadParentDocument(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.load_parent_document" + bl_label = "Load Parent Document" bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - core.load_references(tool.Document) + core.load_parent_document(tool.Document) class DisableDocumentEditingUI(bpy.types.Operator, tool.Ifc.Operator): @@ -90,24 +100,14 @@ class AddDocumentReference(bpy.types.Operator, tool.Ifc.Operator): core.add_reference(tool.Ifc, tool.Document) -class EditInformation(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.edit_information" +class EditDocument(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.edit_document" bl_label = "Edit Information" bl_options = {"REGISTER", "UNDO"} def _execute(self, context): props = context.scene.BIMDocumentProperties - core.edit_information(tool.Ifc, tool.Document, information=tool.Ifc.get().by_id(props.active_document_id)) - - -class EditDocumentReference(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.edit_document_reference" - bl_label = "Edit Document Reference" - bl_options = {"REGISTER", "UNDO"} - - def _execute(self, context): - props = context.scene.BIMDocumentProperties - core.edit_reference(tool.Ifc, tool.Document, reference=tool.Ifc.get().by_id(props.active_document_id)) + core.edit_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(props.active_document_id)) class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator): @@ -120,28 +120,6 @@ class RemoveDocument(bpy.types.Operator, tool.Ifc.Operator): core.remove_document(tool.Ifc, tool.Document, document=tool.Ifc.get().by_id(self.document)) -class EnableAssigningDocument(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.enable_assigning_document" - bl_label = "Enable Assigning Document" - bl_options = {"REGISTER", "UNDO"} - obj: bpy.props.StringProperty() - - def _execute(self, context): - obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - core.enable_assigning_document(tool.Document, obj) - - -class DisableAssigningDocument(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.disable_assigning_document" - bl_label = "Disable Assigning Document" - bl_options = {"REGISTER", "UNDO"} - obj: bpy.props.StringProperty() - - def _execute(self, context): - obj = bpy.data.objects.get(self.obj) if self.obj else context.active_object - core.enable_assigning_document(tool.Document, obj) - - class AssignDocument(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_document" bl_label = "Assign Document" diff --git a/src/blenderbim/blenderbim/bim/module/document/prop.py b/src/blenderbim/blenderbim/bim/module/document/prop.py index a470e715ae..1a9c7412cf 100644 --- a/src/blenderbim/blenderbim/bim/module/document/prop.py +++ b/src/blenderbim/blenderbim/bim/module/document/prop.py @@ -34,6 +34,7 @@ from bpy.props import ( class Document(PropertyGroup): name: StringProperty(name="Name") identification: StringProperty(name="Identification") + is_information: BoolProperty(name="Is Information") ifc_definition_id: IntProperty(name="IFC Definition ID") @@ -41,12 +42,6 @@ class BIMDocumentProperties(PropertyGroup): document_attributes: CollectionProperty(name="Document Attributes", type=Attribute) active_document_id: IntProperty(name="Active Document Id") documents: CollectionProperty(name="Documents", type=Document) + breadcrumbs: CollectionProperty(name="Breadcrumbs", type=StrProperty) active_document_index: IntProperty(name="Active Document Index") - is_editing: StringProperty(name="Is Editing") - - -class BIMObjectDocumentProperties(PropertyGroup): - is_adding: StringProperty(name="Is Adding") - available_document_types: EnumProperty( - items=[(d, d, "") for d in ["IfcDocumentInformation", "IfcDocumentReference"]], name="Available Document Types" - ) + is_editing: BoolProperty(name="Is Editing", default=False) diff --git a/src/blenderbim/blenderbim/bim/module/document/ui.py b/src/blenderbim/blenderbim/bim/module/document/ui.py index a13995f694..71a0ca04cd 100644 --- a/src/blenderbim/blenderbim/bim/module/document/ui.py +++ b/src/blenderbim/blenderbim/bim/module/document/ui.py @@ -41,34 +41,40 @@ class BIM_PT_documents(Panel): self.props = context.scene.BIMDocumentProperties - if not self.props.is_editing or self.props.is_editing == "information": - row = self.layout.row(align=True) - row.label(text="{} Documents Found".format(DocumentData.data["total_information"]), icon="FILE") - if self.props.is_editing == "information": - row.operator("bim.add_information", text="", icon="ADD") - row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") - else: - row.operator("bim.load_information", text="", icon="IMPORT") - - if not self.props.is_editing or self.props.is_editing == "reference": - row = self.layout.row(align=True) - row.label(text="{} References Found".format(DocumentData.data["total_references"]), icon="FILE_HIDDEN") - if self.props.is_editing == "reference": - row.operator("bim.add_document_reference", text="", icon="ADD") - row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") - else: - row.operator("bim.load_document_references", text="", icon="IMPORT") - + row = self.layout.row(align=True) + row.label(text="{} Documents Found".format(DocumentData.data["total_information"]), icon="FILE") if self.props.is_editing: - self.layout.template_list( - "BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index" - ) + row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") + else: + row.operator("bim.load_project_documents", text="", icon="IMPORT") + + if not self.props.is_editing: + return + + row = self.layout.row(align=True) + if self.props.breadcrumbs: + row.operator("bim.load_parent_document", text="", icon="FRAME_PREV") + row.label(text=DocumentData.data["parent_document"]) + else: + row.alignment = "RIGHT" + row.operator("bim.add_information", text="", icon="ADD") + if self.props.breadcrumbs: + row.operator("bim.add_document_reference", text="", icon="FILE_HIDDEN") if self.props.active_document_id: - self.draw_editable_ui(context) + row.operator("bim.edit_document", text="", icon="CHECKMARK") + row.operator("bim.disable_editing_document", text="", icon="CANCEL") + elif self.props.documents and self.props.active_document_index < len(self.props.documents): + ifc_definition_id = self.props.documents[self.props.active_document_index].ifc_definition_id + row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ifc_definition_id + row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id - def draw_editable_ui(self, context): - draw_attributes(self.props.document_attributes, self.layout) + self.layout.template_list( + "BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index" + ) + + if self.props.active_document_id: + draw_attributes(self.props.document_attributes, self.layout) class BIM_PT_object_documents(Panel): @@ -94,8 +100,7 @@ class BIM_PT_object_documents(Panel): obj = context.active_object self.oprops = obj.BIMObjectProperties - self.sprops = context.scene.BIMDocumentProperties - self.props = obj.BIMObjectDocumentProperties + self.props = context.scene.BIMDocumentProperties self.file = IfcStore.get_file() self.draw_add_ui() @@ -111,49 +116,43 @@ class BIM_PT_object_documents(Panel): row.operator("bim.unassign_document", text="", icon="X").document = document["id"] def draw_add_ui(self): - if self.props.is_adding: + if not self.props.is_editing: row = self.layout.row(align=True) - icon = "FILE" if self.props.is_adding == "IfcDocumentInformation" else "FILE_HIDDEN" - row.label(text="Adding {}".format(self.props.is_adding), icon=icon) - row.operator("bim.disable_assigning_document", text="", icon="CANCEL") - self.layout.template_list( - "BIM_UL_object_documents", - "", - self.sprops, - "documents", - self.sprops, - "active_document_index", - ) + row.operator("bim.load_project_documents", text="Assign Document References", icon="ADD") + return + + row = self.layout.row(align=True) + if self.props.breadcrumbs: + row.operator("bim.load_parent_document", text="", icon="FRAME_PREV") + row.label(text=DocumentData.data["parent_document"]) else: - row = self.layout.row(align=True) - row.prop(self.props, "available_document_types", text="") - row.operator("bim.enable_assigning_document", text="", icon="ADD") + row.alignment = "RIGHT" + + if self.props.documents and self.props.active_document_index < len(self.props.documents): + document = self.props.documents[self.props.active_document_index] + if not document.is_information: + row.operator("bim.assign_document", text="", icon="ADD").document = document.ifc_definition_id + row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") + + self.layout.template_list( + "BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index" + ) class BIM_UL_documents(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): if item: row = layout.row(align=True) - row.label(text=item.identification) - row.label(text=item.name) - if context.scene.BIMDocumentProperties.active_document_id == item.ifc_definition_id: - if context.scene.BIMDocumentProperties.is_editing == "information": - row.operator("bim.edit_information", text="", icon="CHECKMARK") - elif context.scene.BIMDocumentProperties.is_editing == "reference": - row.operator("bim.edit_document_reference", text="", icon="CHECKMARK") - row.operator("bim.disable_editing_document", text="", icon="CANCEL") - elif context.scene.BIMDocumentProperties.active_document_id: - row.operator("bim.remove_document", text="", icon="X").document = item.ifc_definition_id - else: - op = row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL") + + if item.is_information: + op = row.operator("bim.load_document", text="", emboss=False, icon="DISCLOSURE_TRI_RIGHT") op.document = item.ifc_definition_id - row.operator("bim.remove_document", text="", icon="X").document = item.ifc_definition_id + row.label(text="", icon="FILE") + else: + row.label(text="", icon="BLANK1") + row.label(text="", icon="FILE_HIDDEN") - -class BIM_UL_object_documents(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): - if item: - row = layout.row(align=True) - row.label(text=item.identification) - row.label(text=item.name) - row.operator("bim.assign_document", text="", icon="ADD").document = item.ifc_definition_id + split1 = row.split(factor=0.1) + split1.label(text=item.identification) + split2 = split1.split(factor=0.9) + split2.label(text=item.name) diff --git a/src/blenderbim/blenderbim/core/document.py b/src/blenderbim/blenderbim/core/document.py index d9e2fa22b1..873af9e642 100644 --- a/src/blenderbim/blenderbim/core/document.py +++ b/src/blenderbim/blenderbim/core/document.py @@ -17,16 +17,31 @@ # along with BlenderBIM Add-on. If not, see . -def load_information(document): - document.import_information() - document.enable_information_editing_ui() - document.disable_editing_document() +def load_project_documents(document): + document.clear_document_tree() + document.import_project_documents() + document.clear_breadcrumbs() + document.enable_editing_ui() -def load_references(document): - document.import_references() - document.enable_reference_editing_ui() - document.disable_editing_document() +def load_document(document_tool, document=None): + document_tool.clear_document_tree() + document_tool.import_subdocuments(document) + document_tool.import_references(document) + document_tool.disable_editing_document() + document_tool.add_breadcrumb(document) + + +def load_parent_document(document): + document.clear_document_tree() + document.remove_latest_breadcrumb() + parent = document.get_active_breadcrumb() + if parent: + document.import_subdocuments(parent) + document.import_references(parent) + document.disable_editing_document() + else: + document.import_project_documents() def disable_document_editing_ui(document): @@ -44,52 +59,52 @@ def disable_editing_document(document): def add_information(ifc, document): - result = ifc.run("document.add_information") - document.import_information() - document.import_document_attributes(result) - document.set_active_document(result) + document.clear_document_tree() + parent = document.get_active_breadcrumb() + ifc.run("document.add_information", parent=parent) + if parent: + document.import_subdocuments(parent) + document.import_references(parent) + else: + document.import_project_documents() def add_reference(ifc, document): - result = ifc.run("document.add_reference") - document.import_references() - document.import_document_attributes(result) - document.set_active_document(result) + parent = document.get_active_breadcrumb() + ifc.run("document.add_reference", information=parent) + document.clear_document_tree() + document.import_subdocuments(parent) + document.import_references(parent) -def edit_information(ifc, document, information=None): - attributes = document.export_document_attributes() - ifc.run("document.edit_information", information=information, attributes=attributes) - document.disable_editing_document() - document.import_information() - - -def edit_reference(ifc, document, reference=None): - attributes = document.export_document_attributes() - ifc.run("document.edit_reference", reference=reference, attributes=attributes) - document.disable_editing_document() - document.import_references() +def edit_document(ifc, document_tool, document=None): + attributes = document_tool.export_document_attributes() + if document_tool.is_document_information(document): + ifc.run("document.edit_information", information=document, attributes=attributes) + else: + ifc.run("document.edit_reference", reference=document, attributes=attributes) + document_tool.disable_editing_document() + document_tool.clear_document_tree() + parent = document_tool.get_active_breadcrumb() + if parent: + document_tool.import_subdocuments(parent) + document_tool.import_references(parent) + else: + document_tool.import_project_documents() def remove_document(ifc, document_tool, document=None): + document_tool.clear_document_tree() if document_tool.is_document_information(document): - ifc.run("document.remove_document", document=document) - document_tool.import_information() + ifc.run("document.remove_information", information=document) else: - ifc.run("document.remove_document", document=document) - document_tool.import_references() - document_tool.disable_editing_document() - - -def enable_assigning_document(document, obj=None): - document.import_references() - document.enable_reference_editing_ui() - document.disable_editing_document() - document.enable_document_assignment_ui(obj) - - -def disable_assigning_document(document, obj=None): - document.disable_document_assignment_ui(obj) + ifc.run("document.remove_reference", reference=document) + parent = document_tool.get_active_breadcrumb() + if parent: + document_tool.import_subdocuments(parent) + document_tool.import_references(parent) + else: + document_tool.import_project_documents() def assign_document(ifc, product=None, document=None): diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index 4a6ce898eb..e37a93a967 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -135,17 +135,20 @@ class Debug: @interface class Document: - def disable_document_assignment_ui(cls, obj): pass + def add_breadcrumb(cls, document): pass + def clear_breadcrumbs(cls): pass + def clear_document_tree(cls): pass def disable_editing_document(cls): pass def disable_editing_ui(cls): pass - def enable_document_assignment_ui(cls, obj): pass - def enable_information_editing_ui(cls): pass - def enable_reference_editing_ui(cls): pass + def enable_editing_ui(cls): pass def export_document_attributes(cls): pass + def get_active_breadcrumb(cls): pass def import_document_attributes(cls, document): pass - def import_information(cls): pass - def import_references(cls): pass + def import_project_documents(cls): pass + def import_references(cls, document): pass + def import_subdocuments(cls, document): pass def is_document_information(cls, document): pass + def remove_latest_breadcrumb(cls): pass def set_active_document(cls, document): pass diff --git a/src/blenderbim/blenderbim/tool/document.py b/src/blenderbim/blenderbim/tool/document.py index a8b80b975d..ce1e68ce49 100644 --- a/src/blenderbim/blenderbim/tool/document.py +++ b/src/blenderbim/blenderbim/tool/document.py @@ -25,8 +25,20 @@ from blenderbim.bim import import_ifc class Document(blenderbim.core.tool.Document): @classmethod - def disable_document_assignment_ui(cls, obj): - obj.BIMObjectDocumentProperties.is_adding = "" + def add_breadcrumb(cls, document): + props = bpy.context.scene.BIMDocumentProperties + new = props.breadcrumbs.add() + new.name = str(document.id()) + + @classmethod + def clear_breadcrumbs(cls): + props = bpy.context.scene.BIMDocumentProperties + props.breadcrumbs.clear() + + @classmethod + def clear_document_tree(cls): + props = bpy.context.scene.BIMDocumentProperties + props.documents.clear() @classmethod def disable_editing_document(cls): @@ -34,24 +46,22 @@ class Document(blenderbim.core.tool.Document): @classmethod def disable_editing_ui(cls): - bpy.context.scene.BIMDocumentProperties.is_editing = "" + bpy.context.scene.BIMDocumentProperties.is_editing = False @classmethod - def enable_document_assignment_ui(cls, obj): - obj.BIMObjectDocumentProperties.is_adding = "IfcDocumentReference" - - @classmethod - def enable_information_editing_ui(cls): - bpy.context.scene.BIMDocumentProperties.is_editing = "information" - - @classmethod - def enable_reference_editing_ui(cls): - bpy.context.scene.BIMDocumentProperties.is_editing = "reference" + def enable_editing_ui(cls): + bpy.context.scene.BIMDocumentProperties.is_editing = True @classmethod def export_document_attributes(cls): return blenderbim.bim.helper.export_attributes(bpy.context.scene.BIMDocumentProperties.document_attributes) + @classmethod + def get_active_breadcrumb(cls): + props = bpy.context.scene.BIMDocumentProperties + if len(props.breadcrumbs): + return tool.Ifc.get().by_id(int(props.breadcrumbs[-1].name)) + @classmethod def import_document_attributes(cls, document): props = bpy.context.scene.BIMDocumentProperties @@ -59,35 +69,64 @@ class Document(blenderbim.core.tool.Document): blenderbim.bim.helper.import_attributes2(document, props.document_attributes) @classmethod - def import_information(cls): + def import_project_documents(cls): props = bpy.context.scene.BIMDocumentProperties props.documents.clear() - for element in tool.Ifc.get().by_type("IfcDocumentInformation"): - new = props.documents.add() - new.ifc_definition_id = element.id() - new.name = element.Name or "Unnamed" - if tool.Ifc.get_schema() == "IFC2X3": - new.identification = element.DocumentId or "*" - else: - new.identification = element.Identification or "*" + project = tool.Ifc.get().by_type("IfcProject")[0] + for rel in project.HasAssociations or []: + if rel.is_a("IfcRelAssociatesDocument") and rel.RelatingDocument.is_a("IfcDocumentInformation"): + element = rel.RelatingDocument + new = props.documents.add() + new.ifc_definition_id = element.id() + new.name = element.Name or "Unnamed" + new.is_information = True + if tool.Ifc.get_schema() == "IFC2X3": + new.identification = element.DocumentId or "*" + else: + new.identification = element.Identification or "*" @classmethod - def import_references(cls): + def import_references(cls, document): props = bpy.context.scene.BIMDocumentProperties - props.documents.clear() - for element in tool.Ifc.get().by_type("IfcDocumentReference"): - new = props.documents.add() - new.ifc_definition_id = element.id() - new.name = element.Name or "Unnamed" - if tool.Ifc.get_schema() == "IFC2X3": + if tool.Ifc.get_schema() == "IFC2X3": + for element in document.DocumentReferences or []: + new = props.documents.add() + new.ifc_definition_id = element.id() + new.name = element.Name or "Unnamed" new.identification = element.ItemReference or "*" - else: + new.is_information = False + else: + for element in document.HasDocumentReferences: + new = props.documents.add() + new.ifc_definition_id = element.id() + new.name = element.Name or "Unnamed" new.identification = element.Identification or "*" + new.is_information = False + + @classmethod + def import_subdocuments(cls, document): + props = bpy.context.scene.BIMDocumentProperties + if document.IsPointer: + for element in document.IsPointer[0].RelatedDocuments or []: + new = props.documents.add() + new.ifc_definition_id = element.id() + new.name = element.Name or "Unnamed" + new.is_information = True + if tool.Ifc.get_schema() == "IFC2X3": + new.identification = element.DocumentId or "*" + else: + new.identification = element.Identification or "*" @classmethod def is_document_information(cls, document): return document.is_a("IfcDocumentInformation") + @classmethod + def remove_latest_breadcrumb(cls): + props = bpy.context.scene.BIMDocumentProperties + if len(props.breadcrumbs): + props.breadcrumbs.remove(len(props.breadcrumbs) - 1) + @classmethod def set_active_document(cls, document): bpy.context.scene.BIMDocumentProperties.active_document_id = document.id() diff --git a/src/blenderbim/pytest.ini b/src/blenderbim/pytest.ini index 2da67b7e82..5cd46aa294 100644 --- a/src/blenderbim/pytest.ini +++ b/src/blenderbim/pytest.ini @@ -7,6 +7,7 @@ markers = context debug demo + document drawing geometry library diff --git a/src/blenderbim/test/bim/feature/document.feature b/src/blenderbim/test/bim/feature/document.feature new file mode 100644 index 0000000000..db14740fbe --- /dev/null +++ b/src/blenderbim/test/bim/feature/document.feature @@ -0,0 +1,112 @@ +@document +Feature: Document + +Scenario: Load project documents + Given an empty IFC project + When I press "bim.load_project_documents" + Then nothing happens + +Scenario: Load document + Given an empty IFC project + And I press "bim.load_project_documents" + And I press "bim.add_information" + And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" + When I press "bim.load_document(document={information})" + Then nothing happens + +Scenario: Load parent document + Given an empty IFC project + And I press "bim.load_project_documents" + And I press "bim.add_information" + And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" + And I press "bim.load_document(document={information})" + When I press "bim.load_parent_document" + Then nothing happens + +Scenario: Disable document editing UI + Given an empty IFC project + And I press "bim.load_project_documents" + When I press "bim.disable_document_editing_ui" + Then nothing happens + +Scenario: Enable editing document + Given an empty IFC project + And I press "bim.load_project_documents" + And I press "bim.add_information" + And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" + When I press "bim.enable_editing_document(document={information})" + Then nothing happens + +Scenario: Disable editing document + Given an empty IFC project + And I press "bim.load_project_documents" + And I press "bim.add_information" + And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" + And I press "bim.enable_editing_document(document={information})" + When I press "bim.disable_editing_document" + Then nothing happens + +Scenario: Add information + Given an empty IFC project + And I press "bim.load_project_documents" + When I press "bim.add_information" + Then nothing happens + +Scenario: Add document reference + Given an empty IFC project + And I press "bim.load_project_documents" + And I press "bim.add_information" + And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" + And I press "bim.load_document(document={information})" + When I press "bim.add_document_reference" + Then nothing happens + +Scenario: Edit document + Given an empty IFC project + And I press "bim.load_project_documents" + And I press "bim.add_information" + And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" + And I press "bim.enable_editing_document(document={information})" + When I press "bim.edit_document" + Then nothing happens + +Scenario: Remove document + Given an empty IFC project + And I press "bim.load_project_documents" + And I press "bim.add_information" + And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" + When I press "bim.remove_document(document={information})" + Then nothing happens + +Scenario: Assign document + Given an empty IFC project + And I press "bim.load_project_documents" + And I press "bim.add_information" + And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" + And I press "bim.load_document(document={information})" + And I press "bim.add_document_reference" + And the variable "reference" is "{ifc}.by_type('IfcDocumentReference')[-1].id()" + And I add a cube + And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" + And I press "bim.assign_class" + When I press "bim.assign_document(document={reference})" + Then nothing happens + +Scenario: Unassign document + Given an empty IFC project + And I press "bim.load_project_documents" + And I press "bim.add_information" + And the variable "information" is "{ifc}.by_type('IfcDocumentInformation')[-1].id()" + And I press "bim.load_document(document={information})" + And I press "bim.add_document_reference" + And the variable "reference" is "{ifc}.by_type('IfcDocumentReference')[-1].id()" + And I add a cube + And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" + And I press "bim.assign_class" + And I press "bim.assign_document(document={reference})" + When I press "bim.unassign_document(document={reference})" + Then nothing happens diff --git a/src/blenderbim/test/core/test_document.py b/src/blenderbim/test/core/test_document.py index cbd9ed3e01..e2ddfe7f45 100644 --- a/src/blenderbim/test/core/test_document.py +++ b/src/blenderbim/test/core/test_document.py @@ -21,20 +21,23 @@ import blenderbim.core.document as subject from test.core.bootstrap import ifc, document -class TestLoadInformation: +class TestLoadProjectDocuments: def test_run(self, document): - document.import_information().should_be_called() - document.enable_information_editing_ui().should_be_called() - document.disable_editing_document().should_be_called() - subject.load_information(document) + document.clear_document_tree().should_be_called() + document.import_project_documents().should_be_called() + document.clear_breadcrumbs().should_be_called() + document.enable_editing_ui().should_be_called() + subject.load_project_documents(document) -class TestLoadReferences: +class TestLoadDocument: def test_run(self, document): - document.import_references().should_be_called() - document.enable_reference_editing_ui().should_be_called() + document.clear_document_tree().should_be_called() + document.import_subdocuments("document").should_be_called() + document.import_references("document").should_be_called() document.disable_editing_document().should_be_called() - subject.load_references(document) + document.add_breadcrumb("document").should_be_called() + subject.load_document(document, document="document") class TestDisableDocumentEditingUi: @@ -58,72 +61,74 @@ class TestDisableEditingDocument: class TestAddInformation: - def test_run(self, ifc, document): - ifc.run("document.add_information").should_be_called().will_return("information") - document.import_information().should_be_called() - document.import_document_attributes("information").should_be_called() - document.set_active_document("information").should_be_called() + def test_add_and_reload_tree_at_project_root(self, ifc, document): + document.clear_document_tree().should_be_called() + document.get_active_breadcrumb().should_be_called().will_return(None) + ifc.run("document.add_information", parent=None).should_be_called() + document.import_project_documents().should_be_called() + subject.add_information(ifc, document) + + def test_add_and_reload_tree_at_current_parent(self, ifc, document): + document.clear_document_tree().should_be_called() + document.get_active_breadcrumb().should_be_called().will_return("parent") + ifc.run("document.add_information", parent="parent").should_be_called() + document.import_subdocuments("parent").should_be_called() + document.import_references("parent").should_be_called() subject.add_information(ifc, document) class TestAddReference: def test_run(self, ifc, document): - ifc.run("document.add_reference").should_be_called().will_return("reference") - document.import_references().should_be_called() - document.import_document_attributes("reference").should_be_called() - document.set_active_document("reference").should_be_called() + document.get_active_breadcrumb().should_be_called().will_return("parent") + ifc.run("document.add_reference", information="parent").should_be_called() + document.clear_document_tree().should_be_called() + document.import_subdocuments("parent").should_be_called() + document.import_references("parent").should_be_called() subject.add_reference(ifc, document) -class TestEditInformation: - def test_run(self, ifc, document): +class TestEditDocument: + def test_edit_information(self, ifc, document): document.export_document_attributes().should_be_called().will_return("attributes") - ifc.run("document.edit_information", information="information", attributes="attributes").should_be_called() + document.is_document_information("document").should_be_called().will_return(True) + ifc.run("document.edit_information", information="document", attributes="attributes").should_be_called() document.disable_editing_document().should_be_called() - document.import_information().should_be_called() - subject.edit_information(ifc, document, information="information") + document.clear_document_tree().should_be_called() + document.get_active_breadcrumb().should_be_called().will_return(None) + document.import_project_documents().should_be_called() + subject.edit_document(ifc, document, document="document") - -class TestEditInformation: - def test_run(self, ifc, document): + def test_edit_reference(self, ifc, document): document.export_document_attributes().should_be_called().will_return("attributes") - ifc.run("document.edit_reference", reference="reference", attributes="attributes").should_be_called() + document.is_document_information("document").should_be_called().will_return(False) + ifc.run("document.edit_reference", reference="document", attributes="attributes").should_be_called() document.disable_editing_document().should_be_called() - document.import_references().should_be_called() - subject.edit_reference(ifc, document, reference="reference") + document.clear_document_tree().should_be_called() + document.get_active_breadcrumb().should_be_called().will_return("parent") + document.import_subdocuments("parent").should_be_called() + document.import_references("parent").should_be_called() + subject.edit_document(ifc, document, document="document") class TestRemoveDocument: def test_remove_information(self, ifc, document): + document.clear_document_tree().should_be_called() document.is_document_information("document").should_be_called().will_return(True) - ifc.run("document.remove_document", document="document").should_be_called() - document.import_information().should_be_called() - document.disable_editing_document().should_be_called() + ifc.run("document.remove_information", information="document").should_be_called() + document.get_active_breadcrumb().should_be_called().will_return(None) + document.import_project_documents().should_be_called() subject.remove_document(ifc, document, document="document") def test_remove_reference(self, ifc, document): + document.clear_document_tree().should_be_called() document.is_document_information("document").should_be_called().will_return(False) - ifc.run("document.remove_document", document="document").should_be_called() - document.import_references().should_be_called() - document.disable_editing_document().should_be_called() + ifc.run("document.remove_reference", reference="document").should_be_called() + document.get_active_breadcrumb().should_be_called().will_return("parent") + document.import_subdocuments("parent").should_be_called() + document.import_references("parent").should_be_called() subject.remove_document(ifc, document, document="document") -class TestEnableAssigningDocument: - def test_run(self, document): - document.import_references().should_be_called() - document.enable_reference_editing_ui().should_be_called() - document.disable_editing_document().should_be_called() - document.enable_document_assignment_ui("obj").should_be_called() - subject.enable_assigning_document(document, obj="obj") - - -class TestDisableAssigningDocument: - def test_run(self, document): - document.disable_document_assignment_ui("obj").should_be_called() - subject.disable_assigning_document(document, obj="obj") - - class TestAssignDocument: def test_run(self, ifc): ifc.run("document.assign_document", product="product", document="document").should_be_called() diff --git a/src/blenderbim/test/tool/test_document.py b/src/blenderbim/test/tool/test_document.py index 8353e5c8f2..b458eedf1e 100644 --- a/src/blenderbim/test/tool/test_document.py +++ b/src/blenderbim/test/tool/test_document.py @@ -29,12 +29,30 @@ class TestImplementsTool(NewFile): assert isinstance(subject(), blenderbim.core.tool.Document) -class TestDisableDocumentAssignmentUI(NewFile): +class TestAddBreadcrumb(NewFile): def test_run(self): - obj = bpy.data.objects.new("Object", None) - obj.BIMObjectDocumentProperties.is_adding = "foo" - subject.disable_document_assignment_ui(obj) - assert obj.BIMObjectDocumentProperties.is_adding == "" + ifc = ifcopenshell.file() + tool.Ifc().set(ifc) + document = ifc.createIfcDocumentInformation() + subject.add_breadcrumb(document) + props = bpy.context.scene.BIMDocumentProperties + assert props.breadcrumbs[0].name == str(document.id()) + + +class TestClearBreadcrumbs(NewFile): + def test_run(self): + props = bpy.context.scene.BIMDocumentProperties + props.breadcrumbs.add() + subject.clear_breadcrumbs() + assert len(props.breadcrumbs) == 0 + + +class TestClearDocumentTree(NewFile): + def test_run(self): + props = bpy.context.scene.BIMDocumentProperties + new = props.documents.add() + subject.clear_document_tree() + assert len(props.documents) == 0 class TestDisableEditingDocument(NewFile): @@ -46,31 +64,16 @@ class TestDisableEditingDocument(NewFile): class TestDisableEditingUI(NewFile): def test_run(self): - bpy.context.scene.BIMDocumentProperties.is_editing = "is_editing" + bpy.context.scene.BIMDocumentProperties.is_editing = True subject.disable_editing_ui() - assert bpy.context.scene.BIMDocumentProperties.is_editing == "" + assert bpy.context.scene.BIMDocumentProperties.is_editing == False -class TestEnableDocumentAssignmentUI(NewFile): +class TestEnableEditingUI(NewFile): def test_run(self): - obj = bpy.data.objects.new("Object", None) - obj.BIMObjectDocumentProperties.is_adding = "" - subject.enable_document_assignment_ui(obj) - assert obj.BIMObjectDocumentProperties.is_adding == "IfcDocumentReference" - - -class TestEnableInformationEditingUI(NewFile): - def test_run(self): - bpy.context.scene.BIMDocumentProperties.is_editing = "" - subject.enable_information_editing_ui() - assert bpy.context.scene.BIMDocumentProperties.is_editing == "information" - - -class TestEnableReferenceEditingUI(NewFile): - def test_run(self): - bpy.context.scene.BIMDocumentProperties.is_editing = "" - subject.enable_reference_editing_ui() - assert bpy.context.scene.BIMDocumentProperties.is_editing == "reference" + bpy.context.scene.BIMDocumentProperties.is_editing = False + subject.enable_editing_ui() + assert bpy.context.scene.BIMDocumentProperties.is_editing == True class TestExportDocumentAttributes(NewFile): @@ -95,6 +98,15 @@ class TestExportDocumentAttributes(NewFile): } +class TestGetActiveBreadcrumb(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc().set(ifc) + document = ifc.createIfcDocumentInformation() + subject.add_breadcrumb(document) + assert subject.get_active_breadcrumb() == document + + class TestImportDocumentAttributes(NewFile): def test_importing_information(self): ifc = ifcopenshell.file() @@ -149,30 +161,51 @@ class TestImportDocumentAttributes(NewFile): assert props.document_attributes.get("Description").string_value == "Description" -class TestImportInformation(NewFile): +class TestImportProjectDocuments(NewFile): def test_run(self): ifc = ifcopenshell.file() tool.Ifc().set(ifc) - document = ifc.createIfcDocumentInformation() - subject.import_information() + ifc.createIfcProject() + document = ifcopenshell.api.run("document.add_information", ifc) + subject.import_project_documents() props = bpy.context.scene.BIMDocumentProperties assert len(props.documents) == 1 assert props.documents[0].ifc_definition_id == document.id() assert props.documents[0].name == "Unnamed" - assert props.documents[0].identification == "*" + assert props.documents[0].identification == "X" + assert props.documents[0].is_information is True -class TestImportReference(NewFile): +class TestImportReferences(NewFile): def test_run(self): ifc = ifcopenshell.file() tool.Ifc().set(ifc) - document = ifc.createIfcDocumentReference() - subject.import_references() + ifc.createIfcProject() + document = ifcopenshell.api.run("document.add_information", ifc) + reference = ifcopenshell.api.run("document.add_reference", ifc, information=document) + subject.import_references(document) props = bpy.context.scene.BIMDocumentProperties assert len(props.documents) == 1 - assert props.documents[0].ifc_definition_id == document.id() + assert props.documents[0].ifc_definition_id == reference.id() assert props.documents[0].name == "Unnamed" - assert props.documents[0].identification == "*" + assert props.documents[0].identification == "X" + assert props.documents[0].is_information is False + + +class TestImportSubdocuments(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc().set(ifc) + ifc.createIfcProject() + document = ifcopenshell.api.run("document.add_information", ifc) + subdocument = ifcopenshell.api.run("document.add_information", ifc, parent=document) + subject.import_subdocuments(document) + props = bpy.context.scene.BIMDocumentProperties + assert len(props.documents) == 1 + assert props.documents[0].ifc_definition_id == subdocument.id() + assert props.documents[0].name == "Unnamed" + assert props.documents[0].identification == "X" + assert props.documents[0].is_information is True class TestIsDocumentInformation(NewFile): @@ -184,6 +217,15 @@ class TestIsDocumentInformation(NewFile): assert subject.is_document_information(reference) is False +class TestRemoveLatestBreadcrumb(NewFile): + def test_run(self): + props = bpy.context.scene.BIMDocumentProperties + props.breadcrumbs.add() + props.breadcrumbs.add() + subject.remove_latest_breadcrumb() + assert len(props.breadcrumbs) == 1 + + class TestSetActiveDocument(NewFile): def test_run(self): ifc = ifcopenshell.file() From 379d6d93920e1bc85a3874db6d87e1f91fb8aa54 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 15 Mar 2022 16:53:31 +1100 Subject: [PATCH 09/85] Minor fix --- src/blenderbim/test/bim/feature/aggregate.feature | 2 +- src/blenderbim/test/bim/feature/geometry.feature | 4 ++-- src/blenderbim/test/bim/feature/model.feature | 2 +- src/blenderbim/test/bim/feature/root.feature | 2 +- src/blenderbim/test/tool/test_style.py | 1 + 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/blenderbim/test/bim/feature/aggregate.feature b/src/blenderbim/test/bim/feature/aggregate.feature index 3d0d15d1ba..acb7197252 100644 --- a/src/blenderbim/test/bim/feature/aggregate.feature +++ b/src/blenderbim/test/bim/feature/aggregate.feature @@ -49,7 +49,7 @@ Scenario: Add aggregate When I press "bim.add_aggregate" Then the object "IfcWall/Cube" is in the collection "IfcElementAssembly/Assembly" And the object "IfcElementAssembly/Assembly" is in the collection "IfcElementAssembly/Assembly" - And the collection "IfcElementAssembly/Assembly" is in the collection "IfcProject/My Project" + And the collection "IfcElementAssembly/Assembly" is in the collection "IfcBuildingStorey/My Storey" Scenario: Add aggregate - with the aggregate inheriting the existing spatial collection Given an empty IFC project diff --git a/src/blenderbim/test/bim/feature/geometry.feature b/src/blenderbim/test/bim/feature/geometry.feature index 5bf3491be0..793f499503 100644 --- a/src/blenderbim/test/bim/feature/geometry.feature +++ b/src/blenderbim/test/bim/feature/geometry.feature @@ -19,7 +19,7 @@ Scenario: Add representation And I press "bim.assign_class" And the object "IfcWall/Cube" is selected Then the object "IfcWall/Cube" data is a "Tessellation" representation of "Model/Body/MODEL_VIEW" - When the variable "context" is "{ifc}.by_type('IfcGeometricRepresentationSubContext')[-1].id()" + When the variable "context" is "[c for c in {ifc}.by_type('IfcGeometricRepresentationSubContext') if c.TargetView == 'PLAN_VIEW'][0].id()" And I set "scene.BIMRootProperties.contexts" to "{context}" And I press "bim.add_representation" Then the object "IfcWall/Cube" data is a "Annotation2D" representation of "Plan/Annotation/PLAN_VIEW" @@ -36,7 +36,7 @@ Scenario: Add representation - add a new representation to a typed instance Then the object "IfcWall/Wall" data is a "Tessellation" representation of "Model/Body/MODEL_VIEW" And the object "IfcWall/Wall.001" data is a "Tessellation" representation of "Model/Body/MODEL_VIEW" When the object "IfcWall/Wall" is selected - And the variable "context" is "{ifc}.by_type('IfcGeometricRepresentationSubContext')[-1].id()" + And the variable "context" is "[c for c in {ifc}.by_type('IfcGeometricRepresentationSubContext') if c.TargetView == 'PLAN_VIEW'][0].id()" And I set "scene.BIMRootProperties.contexts" to "{context}" And I press "bim.add_representation" Then the object "IfcWall/Wall" data is a "Annotation2D" representation of "Plan/Annotation/PLAN_VIEW" diff --git a/src/blenderbim/test/bim/feature/model.feature b/src/blenderbim/test/bim/feature/model.feature index 4a22b908c1..49b85e9641 100644 --- a/src/blenderbim/test/bim/feature/model.feature +++ b/src/blenderbim/test/bim/feature/model.feature @@ -40,7 +40,7 @@ Scenario: Add type instance - add a mesh where existing instances have changed c And I press "bim.add_type_instance" And the object "IfcWall/Wall" data is a "Tessellation" representation of "Model/Body/MODEL_VIEW" And the object "IfcWall/Wall" is selected - And the variable "context" is "{ifc}.by_type('IfcGeometricRepresentationSubContext')[-1].id()" + And the variable "context" is "[c for c in {ifc}.by_type('IfcGeometricRepresentationSubContext') if c.TargetView == 'PLAN_VIEW'][0].id()" And I set "scene.BIMRootProperties.contexts" to "{context}" And I press "bim.add_representation" And the object "IfcWall/Wall" data is a "Annotation2D" representation of "Plan/Annotation/PLAN_VIEW" diff --git a/src/blenderbim/test/bim/feature/root.feature b/src/blenderbim/test/bim/feature/root.feature index e9477fe668..6d3bc39226 100644 --- a/src/blenderbim/test/bim/feature/root.feature +++ b/src/blenderbim/test/bim/feature/root.feature @@ -43,7 +43,7 @@ Scenario: Assign a class to a cube And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" Then the object "IfcWall/Cube" is an "IfcWall" - And the object "IfcWall/Cube" is in the collection "IfcProject/My Project" + And the object "IfcWall/Cube" is in the collection "IfcBuildingStorey/My Storey" And the object "IfcWall/Cube" has a "Tessellation" representation of "Model/Body/MODEL_VIEW" Scenario: Assign a type class to a cube diff --git a/src/blenderbim/test/tool/test_style.py b/src/blenderbim/test/tool/test_style.py index b1fc859453..977f9d4fe3 100644 --- a/src/blenderbim/test/tool/test_style.py +++ b/src/blenderbim/test/tool/test_style.py @@ -116,6 +116,7 @@ class TestGetSurfaceRenderingAttributes(NewFile): "Green": 0.5, "Blue": 0.5, }, + "SpecularColour": 0.0, "SpecularHighlight": {"IfcSpecularRoughness": 0.2}, "ReflectanceMethod": "NOTDEFINED", } From 53a917580281da6b2575d88531258ac9670ff41c Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 15 Mar 2022 18:05:44 +1100 Subject: [PATCH 10/85] You can now browse to select files for any URI reference attributes --- src/blenderbim/blenderbim/bim/helper.py | 5 ++++ src/blenderbim/blenderbim/bim/operator.py | 33 +++++++++++++++++++++++ src/blenderbim/blenderbim/bim/prop.py | 5 ++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/helper.py b/src/blenderbim/blenderbim/bim/helper.py index 230022467b..f0dab98427 100644 --- a/src/blenderbim/blenderbim/bim/helper.py +++ b/src/blenderbim/blenderbim/bim/helper.py @@ -48,6 +48,9 @@ def draw_attribute(attribute, layout, copy_operator=None): if copy_operator: op = layout.operator(f"{copy_operator}", text="", icon="COPYDOWN") op.name = attribute.name + if attribute.is_uri: + op = layout.operator("bim.select_uri_attribute", text="", icon="FILE_FOLDER") + op.data_path = attribute.path_from_id("string_value") def import_attributes(ifc_class, props, data, callback=None): @@ -78,6 +81,8 @@ def import_attribute(attribute, props, data, callback=None): props.remove(len(props) - 1) elif data_type == "string": new.string_value = "" if new.is_null else data[attribute.name()] + if attribute.type_of_attribute().declared_type().name() == "IfcURIReference": + new.is_uri = True elif data_type == "boolean": new.bool_value = False if new.is_null else data[attribute.name()] elif data_type == "integer": diff --git a/src/blenderbim/blenderbim/bim/operator.py b/src/blenderbim/blenderbim/bim/operator.py index 087a6dcede..f4994b1405 100644 --- a/src/blenderbim/blenderbim/bim/operator.py +++ b/src/blenderbim/blenderbim/bim/operator.py @@ -39,6 +39,39 @@ class OpenUri(bpy.types.Operator): return {"FINISHED"} +class SelectURIAttribute(bpy.types.Operator): + bl_idname = "bim.select_uri_attribute" + bl_label = "Select URI Attribute" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Select a local file" + data_path: bpy.props.StringProperty(name="Data Path") + filepath: bpy.props.StringProperty(subtype="FILE_PATH") + + def execute(self, context): + # data_path contains the latter half of the path to the string_value property + # I have no idea how to find out the former half, so let's just use brute force. + data_path = self.data_path.replace(".string_value", "") + attribute = None + try: + attribute = eval(f"bpy.context.scene.{data_path}") + except: + try: + attribute = eval(f"bpy.context.active_object.{data_path}") + except: + try: + attribute = eval(f"bpy.context.active_object.active_material.{data_path}") + except: + # Do you know a better way? + pass + if attribute: + attribute.string_value = self.filepath + return {"FINISHED"} + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} + + class SelectIfcFile(bpy.types.Operator, IFCFileSelector): bl_idname = "bim.select_ifc_file" bl_label = "Select IFC File" diff --git a/src/blenderbim/blenderbim/bim/prop.py b/src/blenderbim/blenderbim/bim/prop.py index 06af23d2e4..50817c4487 100644 --- a/src/blenderbim/blenderbim/bim/prop.py +++ b/src/blenderbim/blenderbim/bim/prop.py @@ -190,10 +190,11 @@ class Attribute(PropertyGroup): bool_value: BoolProperty(name="Value", update=updateAttributeValue) int_value: IntProperty(name="Value", update=updateAttributeValue) float_value: FloatProperty(name="Value", update=updateAttributeValue) - is_null: BoolProperty(name="Is Null") - is_optional: BoolProperty(name="Is Optional") enum_items: StringProperty(name="Value") enum_value: EnumProperty(items=getAttributeEnumValues, name="Value", update=updateAttributeValue) + is_null: BoolProperty(name="Is Null") + is_optional: BoolProperty(name="Is Optional") + is_uri: BoolProperty(name="Is Uri", default=False) def get_value(self): if self.is_null: From 456f45b346227dfe991c93886694891bbf606c45 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 15 Mar 2022 18:58:51 +1100 Subject: [PATCH 11/85] You can now use relative paths when browsing for file URIs. --- src/blenderbim/blenderbim/bim/__init__.py | 17 +++++++++-------- src/blenderbim/blenderbim/bim/operator.py | 9 +++++++-- src/blenderbim/test/tool/test_document.py | 2 +- .../ifcopenshell/api/document/add_reference.py | 8 +++----- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/__init__.py b/src/blenderbim/blenderbim/bim/__init__.py index 52c4900bea..da0194dcec 100644 --- a/src/blenderbim/blenderbim/bim/__init__.py +++ b/src/blenderbim/blenderbim/bim/__init__.py @@ -79,17 +79,18 @@ for name in modules.keys(): classes = [ - operator.OpenUri, - operator.SelectDataDir, - operator.SelectSchemaDir, - operator.SelectIfcFile, - operator.OpenUpstream, + operator.AddIfcFile, operator.BIM_OT_add_section_plane, operator.BIM_OT_remove_section_plane, - operator.ReloadIfcFile, - operator.AddIfcFile, - operator.RemoveIfcFile, operator.ConfigureVisibility, + operator.OpenUpstream, + operator.OpenUri, + operator.ReloadIfcFile, + operator.RemoveIfcFile, + operator.SelectDataDir, + operator.SelectIfcFile, + operator.SelectSchemaDir, + operator.SelectURIAttribute, prop.StrProperty, prop.ObjProperty, prop.Attribute, diff --git a/src/blenderbim/blenderbim/bim/operator.py b/src/blenderbim/blenderbim/bim/operator.py index f4994b1405..7b171961e0 100644 --- a/src/blenderbim/blenderbim/bim/operator.py +++ b/src/blenderbim/blenderbim/bim/operator.py @@ -22,6 +22,7 @@ import json import webbrowser import ifcopenshell import blenderbim.bim.handler +import blenderbim.tool as tool from . import schema from blenderbim.bim.ifc import IfcStore from blenderbim.bim.ui import IFCFileSelector @@ -44,8 +45,9 @@ class SelectURIAttribute(bpy.types.Operator): bl_label = "Select URI Attribute" bl_options = {"REGISTER", "UNDO"} bl_description = "Select a local file" - data_path: bpy.props.StringProperty(name="Data Path") filepath: bpy.props.StringProperty(subtype="FILE_PATH") + data_path: bpy.props.StringProperty(name="Data Path") + use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) def execute(self, context): # data_path contains the latter half of the path to the string_value property @@ -64,7 +66,10 @@ class SelectURIAttribute(bpy.types.Operator): # Do you know a better way? pass if attribute: - attribute.string_value = self.filepath + filepath = self.filepath + if self.use_relative_path: + filepath = os.path.relpath(filepath, os.path.dirname(tool.Ifc.get_path())) + attribute.string_value = filepath return {"FINISHED"} def invoke(self, context, event): diff --git a/src/blenderbim/test/tool/test_document.py b/src/blenderbim/test/tool/test_document.py index b458eedf1e..3ef56744e0 100644 --- a/src/blenderbim/test/tool/test_document.py +++ b/src/blenderbim/test/tool/test_document.py @@ -188,7 +188,7 @@ class TestImportReferences(NewFile): assert len(props.documents) == 1 assert props.documents[0].ifc_definition_id == reference.id() assert props.documents[0].name == "Unnamed" - assert props.documents[0].identification == "X" + assert props.documents[0].identification == "*" assert props.documents[0].is_information is False diff --git a/src/ifcopenshell-python/ifcopenshell/api/document/add_reference.py b/src/ifcopenshell-python/ifcopenshell/api/document/add_reference.py index 824fb834ad..90505d9886 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/document/add_reference.py +++ b/src/ifcopenshell-python/ifcopenshell/api/document/add_reference.py @@ -25,8 +25,6 @@ class Usecase: self.settings[key] = value def execute(self): - id_attribute = "ItemReference" if self.file.schema == "IFC2X3" else "Identification" - attributes = {id_attribute: "X"} - if self.file.schema != "IFC2X3": - attributes["ReferencedDocument"] = self.settings["information"] - return self.file.create_entity("IfcDocumentReference", **attributes) + if self.file.schema == "IFC2X3": + return self.file.create_entity("IfcDocumentReference") + return self.file.create_entity("IfcDocumentReference", ReferencedDocument=self.settings["information"]) From 8f70f462cc1f3c224e7627b343506ef8b384a72d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 15 Mar 2022 19:32:29 +1100 Subject: [PATCH 12/85] You can now easily open associated documents with support for inherited locations --- src/blenderbim/blenderbim/bim/module/document/data.py | 11 ++++++++++- src/blenderbim/blenderbim/bim/module/document/ui.py | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/document/data.py b/src/blenderbim/blenderbim/bim/module/document/data.py index bd15c610ac..136fa2f3a4 100644 --- a/src/blenderbim/blenderbim/bim/module/document/data.py +++ b/src/blenderbim/blenderbim/bim/module/document/data.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with BlenderBIM Add-on. If not, see . +import os import bpy import ifcopenshell import ifcopenshell.util.schema @@ -85,12 +86,20 @@ class ObjectDocumentData: identification = rel.RelatingDocument.ItemReference else: identification = rel.RelatingDocument.Identification + location = rel.RelatingDocument.Location + if location is None and rel.RelatingDocument.ReferencedDocument: + location = rel.RelatingDocument.ReferencedDocument.Location + if location: + if not "://" in location: + if not os.path.isabs(location): + location = os.path.abspath(os.path.join(os.path.dirname(tool.Ifc.get_path()), location)) + location = "file://" + location results.append( { - "type": rel.RelatingDocument.is_a(), "id": rel.RelatingDocument.id(), "identification": identification, "name": rel.RelatingDocument.Name, + "location": location, } ) return results diff --git a/src/blenderbim/blenderbim/bim/module/document/ui.py b/src/blenderbim/blenderbim/bim/module/document/ui.py index 71a0ca04cd..74a8f7cf2b 100644 --- a/src/blenderbim/blenderbim/bim/module/document/ui.py +++ b/src/blenderbim/blenderbim/bim/module/document/ui.py @@ -113,6 +113,8 @@ class BIM_PT_object_documents(Panel): row = self.layout.row(align=True) row.label(text=document["identification"] or "*", icon="FILE") row.label(text=document["name"] or "Unnamed") + if document["location"]: + row.operator("bim.open_uri", icon="URL", text="").uri = document["location"] row.operator("bim.unassign_document", text="", icon="X").document = document["id"] def draw_add_ui(self): From 05834d071f6431e2f9a469eacfe9bc19fbb65376 Mon Sep 17 00:00:00 2001 From: Cyril Waechter Date: Tue, 15 Mar 2022 09:34:37 +0100 Subject: [PATCH 13/85] Handle empty ConnectionGeometry + refactor (#2085) --- .../bim/module/boundary/operator.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/boundary/operator.py b/src/blenderbim/blenderbim/bim/module/boundary/operator.py index 05e91aad9a..2aad9c1bc2 100644 --- a/src/blenderbim/blenderbim/bim/module/boundary/operator.py +++ b/src/blenderbim/blenderbim/bim/module/boundary/operator.py @@ -44,6 +44,19 @@ class Loader: self.load_settings() self.load_importer() + def create_mesh(self, boundary): + # ConnectionGeometry is optional in IFC schema for some reasons. + if not boundary.ConnectionGeometry: + return None + surface = boundary.ConnectionGeometry.SurfaceOnRelatingElement + # workaround for unvalid geometry provided by Revit. See https://github.com/IfcOpenShell/IfcOpenShell/issues/635#issuecomment-770366838 + if surface.is_a("IfcCurveBoundedPlane") and not getattr(surface, "InnerBoundaries", None): + surface.InnerBoundaries = () + shape = ifcopenshell.geom.create_shape(self.settings, surface) + mesh = self.ifc_importer.create_mesh(None, shape) + self.ifc_importer.link_mesh(shape, mesh) + return mesh + def load_settings(self): self.settings = ifcopenshell.geom.settings() self.settings.set(self.settings.EXCLUDE_SOLIDS_AND_SURFACES, False) @@ -61,13 +74,7 @@ class Loader: obj = tool.Ifc.get_object(boundary) if obj: return obj - surface = boundary.ConnectionGeometry.SurfaceOnRelatingElement - # workaround for unvalid geometry provided by Revit. See https://github.com/IfcOpenShell/IfcOpenShell/issues/635#issuecomment-770366838 - if surface.is_a("IfcCurveBoundedPlane") and not getattr(surface, "InnerBoundaries", None): - surface.InnerBoundaries = () - shape = ifcopenshell.geom.create_shape(self.settings, surface) - mesh = self.ifc_importer.create_mesh(None, shape) - self.ifc_importer.link_mesh(shape, mesh) + mesh = self.create_mesh(boundary) obj = bpy.data.objects.new(f"{boundary.is_a()}/{boundary.Name}", mesh) obj.matrix_world = blender_space.matrix_world boundaries_collection = get_boundaries_collection(blender_space) From 74e18a56ce1b7e8c25fe61e04b2eacada2941533 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 15 Mar 2022 19:53:56 +1100 Subject: [PATCH 14/85] Fall back to information data if reference data is null for associated documents. --- .../blenderbim/bim/module/document/data.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/document/data.py b/src/blenderbim/blenderbim/bim/module/document/data.py index 136fa2f3a4..3b056bec32 100644 --- a/src/blenderbim/blenderbim/bim/module/document/data.py +++ b/src/blenderbim/blenderbim/bim/module/document/data.py @@ -82,10 +82,20 @@ class ObjectDocumentData: if rel.is_a("IfcRelAssociatesDocument"): if not rel.RelatingDocument.is_a("IfcDocumentReference"): continue + + name = rel.RelatingDocument.Name + if not name and rel.RelatingDocument.ReferencedDocument: + name = rel.RelatingDocument.ReferencedDocument.Name + if tool.Ifc.get_schema() == "IFC2X3": identification = rel.RelatingDocument.ItemReference + if not identification and rel.RelatingDocument.ReferencedDocument: + identification = rel.RelatingDocument.ReferencedDocument.DocumentId else: identification = rel.RelatingDocument.Identification + if not identification and rel.RelatingDocument.ReferencedDocument: + identification = rel.RelatingDocument.ReferencedDocument.Identification + location = rel.RelatingDocument.Location if location is None and rel.RelatingDocument.ReferencedDocument: location = rel.RelatingDocument.ReferencedDocument.Location @@ -94,11 +104,12 @@ class ObjectDocumentData: if not os.path.isabs(location): location = os.path.abspath(os.path.join(os.path.dirname(tool.Ifc.get_path()), location)) location = "file://" + location + results.append( { "id": rel.RelatingDocument.id(), "identification": identification, - "name": rel.RelatingDocument.Name, + "name": name, "location": location, } ) From 983a1530b86713290c059160bdef24bfdca50693 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 16 Mar 2022 09:09:43 +1100 Subject: [PATCH 15/85] #2053. Potentially fix geometry issues with importing small objects. --- src/blenderbim/blenderbim/bim/import_ifc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/blenderbim/blenderbim/bim/import_ifc.py b/src/blenderbim/blenderbim/bim/import_ifc.py index 45a074fd0b..4cf902bd45 100644 --- a/src/blenderbim/blenderbim/bim/import_ifc.py +++ b/src/blenderbim/blenderbim/bim/import_ifc.py @@ -163,6 +163,7 @@ class IfcImporter: self.settings = ifcopenshell.geom.settings() self.settings.set_deflection_tolerance(self.ifc_import_settings.deflection_tolerance) self.settings.set_angular_tolerance(self.ifc_import_settings.angular_tolerance) + self.settings.set(self.settings.STRICT_TOLERANCE, True) self.settings_native = ifcopenshell.geom.settings() self.settings_native.set(self.settings_native.INCLUDE_CURVES, True) self.settings_2d = ifcopenshell.geom.settings() From ca3b4d17ba93dda697e2e3ff2c277af2806085b7 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 16 Mar 2022 09:37:20 +1100 Subject: [PATCH 16/85] Fix #2055. Disable IfcContext in IFC2X3 because it didn't exist. --- .../blenderbim/bim/module/root/data.py | 26 ++++++++++++++++ .../blenderbim/bim/module/root/prop.py | 31 +++---------------- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/root/data.py b/src/blenderbim/blenderbim/bim/module/root/data.py index 365bd4ac54..a6d3270e33 100644 --- a/src/blenderbim/blenderbim/bim/module/root/data.py +++ b/src/blenderbim/blenderbim/bim/module/root/data.py @@ -33,12 +33,38 @@ class IfcClassData: def load(cls): cls.is_loaded = True cls.data = { + "ifc_products": cls.ifc_products(), "contexts": cls.contexts(), "has_entity": cls.has_entity(), "name": cls.name(), "ifc_class": cls.ifc_class(), } + @classmethod + def ifc_products(cls): + products = [ + "IfcElement", + "IfcElementType", + "IfcSpatialElement", + "IfcGroup", + "IfcStructuralItem", + "IfcContext", + "IfcAnnotation", + "IfcRelSpaceBoundary", + ] + if tool.Ifc.get_schema() == "IFC2X3": + products = [ + "IfcElement", + "IfcElementType", + "IfcSpatialStructureElement", + "IfcGroup", + "IfcStructuralItem", + "IfcAnnotation", + "IfcRelSpaceBoundary", + ] + return [(e, e, "") for e in products] + + @classmethod def contexts(cls): results = [] diff --git a/src/blenderbim/blenderbim/bim/module/root/prop.py b/src/blenderbim/blenderbim/bim/module/root/prop.py index fd96b0c7cc..74bdc4679a 100644 --- a/src/blenderbim/blenderbim/bim/module/root/prop.py +++ b/src/blenderbim/blenderbim/bim/module/root/prop.py @@ -33,16 +33,13 @@ from bpy.props import ( CollectionProperty, ) -products_enum = [] classes_enum = [] types_enum = [] def purge(): - global products_enum global classes_enum global types_enum - products_enum = [] classes_enum = [] types_enum = [] @@ -76,28 +73,10 @@ def refreshPredefinedTypes(self, context): context.scene.BIMRootProperties.ifc_predefined_type = enum[0][0] -def getIfcProducts(self, context): - global products_enum - file = IfcStore.get_file() - if len(products_enum) < 1: - products_enum.extend( - [ - (e, e, "") - for e in [ - "IfcElement", - "IfcElementType", - "IfcSpatialElement", - "IfcGroup", - "IfcStructuralItem", - "IfcContext", - "IfcAnnotation", - "IfcRelSpaceBoundary", - ] - ] - ) - if file.schema == "IFC2X3": - products_enum[2] = ("IfcSpatialStructureElement", "IfcSpatialStructureElement", "") - return products_enum +def get_ifc_products(self, context): + if not IfcClassData.is_loaded: + IfcClassData.load() + return IfcClassData.data["ifc_products"] def getIfcClasses(self, context): @@ -118,7 +97,7 @@ def get_contexts(self, context): class BIMRootProperties(PropertyGroup): contexts: EnumProperty(items=get_contexts, name="Contexts") - ifc_product: EnumProperty(items=getIfcProducts, name="Products", update=refreshClasses) + ifc_product: EnumProperty(items=get_ifc_products, name="Products", update=refreshClasses) ifc_class: EnumProperty(items=getIfcClasses, name="Class", update=refreshPredefinedTypes) ifc_predefined_type: EnumProperty(items=getIfcPredefinedTypes, name="Predefined Type", default=None) ifc_userdefined_type: StringProperty(name="Userdefined Type") From b5a58998d5121ba016f9ceb47d629546fac1efd8 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 16 Mar 2022 09:58:52 +1100 Subject: [PATCH 17/85] Fix #2083. hpp-fcl now has support for Python 3.10. --- src/blenderbim/Makefile | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/blenderbim/Makefile b/src/blenderbim/Makefile index af7caa2423..1b1a94f939 100644 --- a/src/blenderbim/Makefile +++ b/src/blenderbim/Makefile @@ -46,8 +46,7 @@ BOOST_URL:=https://anaconda.org/conda-forge/boost/1.74.0/download/linux-64/boost LXML_URL:=https://files.pythonhosted.org/packages/19/d9/a69c6aff5673554df48120565a14a50eaa41d29ae03b02faa0b023666318/lxml-4.6.3-cp39-cp39-manylinux2014_x86_64.whl endif ifeq ($(PYVERSION), py310) -# https://github.com/humanoid-path-planner/hpp-fcl/issues/272 -HPPFCL_URL:=https://anaconda.org/conda-forge/hpp-fcl/1.7.5/download/linux-64/hpp-fcl-1.7.5-py39hbcdfc36_0.tar.bz2 +HPPFCL_URL:=https://anaconda.org/conda-forge/hpp-fcl/1.8.0/download/linux-64/hpp-fcl-1.8.0-py310hdaf7e41_1.tar.bz2 EIGENPY_URL:=https://anaconda.org/conda-forge/eigenpy/2.6.11/download/linux-64/eigenpy-2.6.11-py310hf3e5c9c_0.tar.bz2 BOOST_URL:=https://anaconda.org/conda-forge/boost/1.74.0/download/linux-64/boost-1.74.0-py310h7c3ba0c_5.tar.bz2 LXML_URL:=https://files.pythonhosted.org/packages/25/1e/19b46d8e8881fe0df2e20945d51919eeb1817836d62a90efa8506530e45c/lxml-4.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl @@ -71,8 +70,7 @@ BOOST_URL:=https://anaconda.org/conda-forge/boost/1.74.0/download/osx-64/boost-1 LXML_URL:=https://files.pythonhosted.org/packages/b8/74/a71f7ad72e8db54ce899efab84507b801660750cbbfa6a39e6717557d36a/lxml-4.6.3-cp39-cp39-macosx_10_9_x86_64.whl endif ifeq ($(PYVERSION), py310) -# https://github.com/humanoid-path-planner/hpp-fcl/issues/272 -HPPFCL_URL:=https://anaconda.org/conda-forge/hpp-fcl/1.7.5/download/osx-64/hpp-fcl-1.7.5-py39h1e32b98_0.tar.bz2 +HPPFCL_URL:=https://anaconda.org/conda-forge/hpp-fcl/1.8.0/download/osx-64/hpp-fcl-1.8.0-py310h651ac30_1.tar.bz2 EIGENPY_URL:=https://anaconda.org/conda-forge/eigenpy/2.6.11/download/osx-64/eigenpy-2.6.11-py310hc03097c_0.tar.bz2 BOOST_URL:=https://anaconda.org/conda-forge/boost/1.74.0/download/osx-64/boost-1.74.0-py310h509978a_5.tar.bz2 LXML_URL:=https://files.pythonhosted.org/packages/a1/44/17b7dac7a18807d30e2fe10c3328c152808f5464565e230bfd0e77f178c6/lxml-4.8.0-cp310-cp310-macosx_10_15_x86_64.whl @@ -96,8 +94,7 @@ BOOST_URL:=https://anaconda.org/conda-forge/boost/1.74.0/download/win-64/boost-1 LXML_URL:=https://files.pythonhosted.org/packages/72/d4/426ecb8849c47c3e370c87aa0ac05d85768df917ffea27fcd6686a5e6495/lxml-4.6.3-cp39-cp39-win_amd64.whl endif ifeq ($(PYVERSION), py310) -# https://github.com/humanoid-path-planner/hpp-fcl/issues/272 -HPPFCL_URL:=https://anaconda.org/conda-forge/hpp-fcl/1.7.5/download/win-64/hpp-fcl-1.7.5-py39h2e7c763_0.tar.bz2 +HPPFCL_URL:=https://anaconda.org/conda-forge/hpp-fcl/1.8.0/download/win-64/hpp-fcl-1.8.0-py310hc5a3c62_1.tar.bz2 EIGENPY_URL:=https://anaconda.org/conda-forge/eigenpy/2.6.11/download/win-64/eigenpy-2.6.11-py310hbd43d28_0.tar.bz2 BOOST_URL:=https://anaconda.org/conda-forge/boost/1.74.0/download/win-64/boost-1.74.0-py310hc781a3c_5.tar.bz2 LXML_URL:=https://files.pythonhosted.org/packages/f6/71/65c80a4caa1617a4c6e8fe1500cebb179db96232e2f623bfe6a1f4294e39/lxml-4.8.0-cp310-cp310-win_amd64.whl @@ -292,8 +289,6 @@ endif rm -rf dist/working # Required by IFCClash -# https://github.com/humanoid-path-planner/hpp-fcl/issues/272 -ifneq ($(PYVERSION), py310) mkdir dist/working cd dist/working && wget $(HPPFCL_URL) cd dist/working && tar -xf hpp-fcl* @@ -385,8 +380,6 @@ ifeq ($(PLATFORM), win) cp -r dist/working/Library/bin/*.dll dist/blenderbim/libs/site/packages/hppfcl/ endif rm -rf dist/working -# https://github.com/humanoid-path-planner/hpp-fcl/issues/272 -endif # Required by BIMTester mkdir dist/working From 3713ec00f9b2b8a4f6ff47fb9b80222754024cae Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 16 Mar 2022 10:46:29 +1100 Subject: [PATCH 18/85] #2049. You can now edit linked project paths when unloaded. Thanks brunoperdigao! --- src/blenderbim/blenderbim/bim/module/project/ui.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/project/ui.py b/src/blenderbim/blenderbim/bim/module/project/ui.py index cd962563d5..271ce2de55 100644 --- a/src/blenderbim/blenderbim/bim/module/project/ui.py +++ b/src/blenderbim/blenderbim/bim/module/project/ui.py @@ -284,11 +284,12 @@ class BIM_UL_links(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): if item: row = layout.row(align=True) - row.label(text=item.name) if item.is_loaded: + row.label(text=item.name) op = row.operator("bim.unload_link", text="", icon="UNLINKED") op.filepath = item.name else: + row.prop(item, "name", text="") op = row.operator("bim.load_link", text="", icon="LINKED") op.filepath = item.name op = row.operator("bim.unlink_ifc", text="", icon="X") From bddc64cad32cd9fe4e0ff992953e1319b2518883 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 16 Mar 2022 10:55:36 +1100 Subject: [PATCH 19/85] Fix #2049. Allow relative paths for IFC links. --- .../blenderbim/bim/module/project/operator.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/project/operator.py b/src/blenderbim/blenderbim/bim/module/project/operator.py index 8ae9a03201..f536698149 100644 --- a/src/blenderbim/blenderbim/bim/module/project/operator.py +++ b/src/blenderbim/blenderbim/bim/module/project/operator.py @@ -611,10 +611,14 @@ class LinkIfc(bpy.types.Operator): bl_description = "Link a Blender file" filepath: bpy.props.StringProperty(subtype="FILE_PATH") filter_glob: bpy.props.StringProperty(default="*.blend;*.blend1", options={"HIDDEN"}) + use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) def execute(self, context): new = context.scene.BIMProjectProperties.links.add() - new.name = self.filepath + filepath = self.filepath + if self.use_relative_path: + filepath = os.path.relpath(filepath, bpy.path.abspath("//")) + new.name = filepath bpy.ops.bim.load_link(filepath=self.filepath) return {"FINISHED"} @@ -646,13 +650,16 @@ class UnloadLink(bpy.types.Operator): filepath: bpy.props.StringProperty() def execute(self, context): + filepath = self.filepath + if not os.path.isabs(filepath): + filepath = os.path.abspath(os.path.join(bpy.path.abspath("//"), filepath)) for collection in context.scene.collection.children: - if collection.library and collection.library.filepath == self.filepath: + if collection.library and collection.library.filepath == filepath: context.scene.collection.children.unlink(collection) for scene in bpy.data.scenes: - if scene.library and scene.library.filepath == self.filepath: + if scene.library and scene.library.filepath == filepath: bpy.data.scenes.remove(scene) - link = context.scene.BIMProjectProperties.links.get(self.filepath) + link = context.scene.BIMProjectProperties.links.get(filepath) link.is_loaded = False return {"FINISHED"} @@ -665,16 +672,19 @@ class LoadLink(bpy.types.Operator): filepath: bpy.props.StringProperty() def execute(self, context): + filepath = self.filepath + if not os.path.isabs(filepath): + filepath = os.path.abspath(os.path.join(bpy.path.abspath("//"), filepath)) with bpy.data.libraries.load(self.filepath, link=True) as (data_from, data_to): data_to.scenes = data_from.scenes for scene in bpy.data.scenes: - if not scene.library or scene.library.filepath != self.filepath: + if not scene.library or scene.library.filepath != filepath: continue for child in scene.collection.children: if "IfcProject" not in child.name: continue bpy.data.scenes[0].collection.children.link(child) - link = context.scene.BIMProjectProperties.links.get(self.filepath) + link = context.scene.BIMProjectProperties.links.get(filepath) link.is_loaded = True return {"FINISHED"} From 44b786a21b1af348cc47b6efbee08db38de7cabe Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 16 Mar 2022 19:11:13 +1100 Subject: [PATCH 20/85] Ensure that exported 4D schedules use a .xml extension --- src/blenderbim/blenderbim/bim/module/sequence/operator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/sequence/operator.py b/src/blenderbim/blenderbim/bim/module/sequence/operator.py index 7626b001ab..a8a600e131 100644 --- a/src/blenderbim/blenderbim/bim/module/sequence/operator.py +++ b/src/blenderbim/blenderbim/bim/module/sequence/operator.py @@ -1230,7 +1230,7 @@ class ExportMSP(bpy.types.Operator, ImportHelper): start = time.time() ifc2msp = Ifc2Msp() ifc2msp.work_schedule = self.file.by_type("IfcWorkSchedule")[0] - ifc2msp.xml = self.filepath + ifc2msp.xml = bpy.path.ensure_ext(self.filepath, ".xml") ifc2msp.file = self.file ifc2msp.holiday_start_date = parser.parse(self.holiday_start_date).date() ifc2msp.holiday_finish_date = parser.parse(self.holiday_finish_date).date() @@ -1259,7 +1259,7 @@ class ExportP6(bpy.types.Operator, ImportHelper): self.file = IfcStore.get_file() start = time.time() ifc2p6 = Ifc2P6() - ifc2p6.xml = self.filepath + ifc2p6.xml = bpy.path.ensure_ext(self.filepath, ".xml") ifc2p6.file = self.file ifc2p6.holiday_start_date = parser.parse(self.holiday_start_date).date() ifc2p6.holiday_finish_date = parser.parse(self.holiday_finish_date).date() From b39a0d1a7cf6c38fb0b891fa8976db1da09d1ff0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 16 Mar 2022 19:11:37 +1100 Subject: [PATCH 21/85] #2053. Revert strict tolerance due to side-effects with caching --- src/blenderbim/blenderbim/bim/import_ifc.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/import_ifc.py b/src/blenderbim/blenderbim/bim/import_ifc.py index 4cf902bd45..5db604d555 100644 --- a/src/blenderbim/blenderbim/bim/import_ifc.py +++ b/src/blenderbim/blenderbim/bim/import_ifc.py @@ -163,7 +163,8 @@ class IfcImporter: self.settings = ifcopenshell.geom.settings() self.settings.set_deflection_tolerance(self.ifc_import_settings.deflection_tolerance) self.settings.set_angular_tolerance(self.ifc_import_settings.angular_tolerance) - self.settings.set(self.settings.STRICT_TOLERANCE, True) + # See https://github.com/IfcOpenShell/IfcOpenShell/issues/2053 + # self.settings.set(self.settings.STRICT_TOLERANCE, True) self.settings_native = ifcopenshell.geom.settings() self.settings_native.set(self.settings_native.INCLUDE_CURVES, True) self.settings_2d = ifcopenshell.geom.settings() From a53fd97e5a849df5eae2e5d26a121880bfd2a605 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 17 Mar 2022 09:49:55 +1100 Subject: [PATCH 22/85] Fix #2092. Fix bug where door and window styles were excluded from authoring options. --- src/blenderbim/blenderbim/bim/import_ifc.py | 20 +++++++------- .../blenderbim/bim/module/bimtester/prop.py | 7 ++--- .../blenderbim/bim/module/document/ui.py | 8 ++---- .../blenderbim/bim/module/model/data.py | 7 ++++- .../blenderbim/bim/module/model/mep.py | 1 - .../blenderbim/bim/module/root/data.py | 23 +++++++++++----- .../blenderbim/bim/module/root/prop.py | 26 +++++++------------ .../util/entity_to_type_map_4.json | 6 +++-- 8 files changed, 50 insertions(+), 48 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/import_ifc.py b/src/blenderbim/blenderbim/bim/import_ifc.py index 5db604d555..4f9149f974 100644 --- a/src/blenderbim/blenderbim/bim/import_ifc.py +++ b/src/blenderbim/blenderbim/bim/import_ifc.py @@ -285,11 +285,13 @@ class IfcImporter: if c.ContextIdentifier in ["Body", "Facetation"] ] # Ideally, all representations should be in a subcontext, but some BIM programs don't do this correctly - self.body_contexts.extend([ - c.id() - for c in self.file.by_type("IfcGeometricRepresentationContext", include_subtypes=False) - if c.ContextType == "Model" - ]) + self.body_contexts.extend( + [ + c.id() + for c in self.file.by_type("IfcGeometricRepresentationContext", include_subtypes=False) + if c.ContextType == "Model" + ] + ) if self.body_contexts: self.settings.set_context_ids(self.body_contexts) # Annotation is to accommodate broken Revit files @@ -365,7 +367,7 @@ class IfcImporter: def is_native_swept_disk_solid(self, representations): for representation in representations: - items = representation["raw"].Items or [] # Be forgiving of invalid IFCs because Revit :( + items = representation["raw"].Items or [] # Be forgiving of invalid IFCs because Revit :( if len(items) == 1 and items[0].is_a("IfcSweptDiskSolid"): return True return False @@ -1724,10 +1726,10 @@ class IfcImporter: ): verts = [None] * len(geometry.verts) for i in range(0, len(geometry.verts), 3): - verts[i], verts[i+1], verts[i+2] = ifcopenshell.util.geolocation.enh2xyz( + verts[i], verts[i + 1], verts[i + 2] = ifcopenshell.util.geolocation.enh2xyz( geometry.verts[i], - geometry.verts[i+1], - geometry.verts[i+2], + geometry.verts[i + 1], + geometry.verts[i + 2], float(props.blender_eastings) * self.unit_scale, float(props.blender_northings) * self.unit_scale, float(props.blender_orthogonal_height) * self.unit_scale, diff --git a/src/blenderbim/blenderbim/bim/module/bimtester/prop.py b/src/blenderbim/blenderbim/bim/module/bimtester/prop.py index e3b438423b..a2ce7dad0d 100644 --- a/src/blenderbim/blenderbim/bim/module/bimtester/prop.py +++ b/src/blenderbim/blenderbim/bim/module/bimtester/prop.py @@ -19,7 +19,7 @@ import os from pathlib import Path from blenderbim.bim.prop import StrProperty -from blenderbim.bim.module.root.prop import getIfcClasses +from blenderbim.bim.module.root.prop import get_ifc_classes from bpy.types import PropertyGroup from bpy.props import ( PointerProperty, @@ -33,14 +33,11 @@ from bpy.props import ( ) scenarios_enum = [] -classes_enum = [] def purge(): global scenarios_enum - global classes_enum scenarios_enum = [] - classes_enum = [] def getScenarios(self, context): @@ -68,7 +65,7 @@ class BimTesterProperties(PropertyGroup): feature: StringProperty(default="", name="Feature / IDS", update=refreshScenarios) steps: StringProperty(default="", name="Custom Steps") ifc_file: StringProperty(default="", name="IFC File") - audit_ifc_class: EnumProperty(items=getIfcClasses, name="Audit Class") + audit_ifc_class: EnumProperty(items=get_ifc_classes, name="Audit Class") qa_reject_element_reason: StringProperty(name="Element Rejection Reason") scenario: EnumProperty(items=getScenarios, name="Scenario") should_load_from_memory: BoolProperty(default=False, name="Load from Memory") diff --git a/src/blenderbim/blenderbim/bim/module/document/ui.py b/src/blenderbim/blenderbim/bim/module/document/ui.py index 74a8f7cf2b..ba1b4704b4 100644 --- a/src/blenderbim/blenderbim/bim/module/document/ui.py +++ b/src/blenderbim/blenderbim/bim/module/document/ui.py @@ -69,9 +69,7 @@ class BIM_PT_documents(Panel): row.operator("bim.enable_editing_document", text="", icon="GREASEPENCIL").document = ifc_definition_id row.operator("bim.remove_document", text="", icon="X").document = ifc_definition_id - self.layout.template_list( - "BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index" - ) + self.layout.template_list("BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index") if self.props.active_document_id: draw_attributes(self.props.document_attributes, self.layout) @@ -136,9 +134,7 @@ class BIM_PT_object_documents(Panel): row.operator("bim.assign_document", text="", icon="ADD").document = document.ifc_definition_id row.operator("bim.disable_document_editing_ui", text="", icon="CANCEL") - self.layout.template_list( - "BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index" - ) + self.layout.template_list("BIM_UL_documents", "", self.props, "documents", self.props, "active_document_index") class BIM_UL_documents(UIList): diff --git a/src/blenderbim/blenderbim/bim/module/model/data.py b/src/blenderbim/blenderbim/bim/module/model/data.py index 704b4f5228..1ee9a3f7be 100644 --- a/src/blenderbim/blenderbim/bim/module/model/data.py +++ b/src/blenderbim/blenderbim/bim/module/model/data.py @@ -39,7 +39,12 @@ class AuthoringData: @classmethod def ifc_classes(cls): results = [] - classes = {e.is_a() for e in tool.Ifc.get().by_type("IfcElementType")} + classes = { + e.is_a() + for e in tool.Ifc.get().by_type("IfcElementType") + + tool.Ifc.get().by_type("IfcDoorStyle") + + tool.Ifc.get().by_type("IfcWindowStyle") + } results.extend([(c, c, "") for c in sorted(classes)]) return results diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index 14784936d4..e971318d78 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -34,7 +34,6 @@ from math import pi, degrees from mathutils import Vector, Matrix - class MepGenerator: def __init__(self, relating_type): self.relating_type = relating_type diff --git a/src/blenderbim/blenderbim/bim/module/root/data.py b/src/blenderbim/blenderbim/bim/module/root/data.py index a6d3270e33..9d2ee77ae4 100644 --- a/src/blenderbim/blenderbim/bim/module/root/data.py +++ b/src/blenderbim/blenderbim/bim/module/root/data.py @@ -32,13 +32,13 @@ class IfcClassData: @classmethod def load(cls): cls.is_loaded = True - cls.data = { - "ifc_products": cls.ifc_products(), - "contexts": cls.contexts(), - "has_entity": cls.has_entity(), - "name": cls.name(), - "ifc_class": cls.ifc_class(), - } + cls.data = {} + cls.data["ifc_products"] = cls.ifc_products() + cls.data["ifc_classes"] = cls.ifc_classes() + cls.data["contexts"] = cls.contexts() + cls.data["has_entity"] = cls.has_entity() + cls.data["name"] = cls.name() + cls.data["ifc_class"] = cls.ifc_class() @classmethod def ifc_products(cls): @@ -64,6 +64,15 @@ class IfcClassData: ] return [(e, e, "") for e in products] + @classmethod + def ifc_classes(cls): + ifc_product = bpy.context.scene.BIMRootProperties.ifc_product + declaration = tool.Ifc.schema().declaration_by_name(ifc_product) + declarations = ifcopenshell.util.schema.get_subtypes(declaration) + names = [d.name() for d in declarations] + if ifc_product == "IfcElementType": + names.extend(("IfcDoorStyle", "IfcWindowStyle")) + return [(c, c, "") for c in sorted(names)] @classmethod def contexts(cls): diff --git a/src/blenderbim/blenderbim/bim/module/root/prop.py b/src/blenderbim/blenderbim/bim/module/root/prop.py index 74bdc4679a..8b598a2338 100644 --- a/src/blenderbim/blenderbim/bim/module/root/prop.py +++ b/src/blenderbim/blenderbim/bim/module/root/prop.py @@ -33,14 +33,11 @@ from bpy.props import ( CollectionProperty, ) -classes_enum = [] types_enum = [] def purge(): - global classes_enum global types_enum - classes_enum = [] types_enum = [] @@ -58,10 +55,9 @@ def getIfcPredefinedTypes(self, context): return types_enum -def refreshClasses(self, context): - global classes_enum - classes_enum.clear() - enum = getIfcClasses(self, context) +def refresh_classes(self, context): + IfcClassData.load() + enum = get_ifc_classes(self, context) context.scene.BIMRootProperties.ifc_class = enum[0][0] @@ -79,14 +75,10 @@ def get_ifc_products(self, context): return IfcClassData.data["ifc_products"] -def getIfcClasses(self, context): - global classes_enum - file = IfcStore.get_file() - if len(classes_enum) < 1 and file: - declaration = IfcStore.get_schema().declaration_by_name(context.scene.BIMRootProperties.ifc_product) - declarations = ifcopenshell.util.schema.get_subtypes(declaration) - classes_enum.extend([(c, c, "") for c in sorted([d.name() for d in declarations])]) - return classes_enum +def get_ifc_classes(self, context): + if not IfcClassData.is_loaded: + IfcClassData.load() + return IfcClassData.data["ifc_classes"] def get_contexts(self, context): @@ -97,7 +89,7 @@ def get_contexts(self, context): class BIMRootProperties(PropertyGroup): contexts: EnumProperty(items=get_contexts, name="Contexts") - ifc_product: EnumProperty(items=get_ifc_products, name="Products", update=refreshClasses) - ifc_class: EnumProperty(items=getIfcClasses, name="Class", update=refreshPredefinedTypes) + ifc_product: EnumProperty(items=get_ifc_products, name="Products", update=refresh_classes) + ifc_class: EnumProperty(items=get_ifc_classes, name="Class", update=refreshPredefinedTypes) ifc_predefined_type: EnumProperty(items=getIfcPredefinedTypes, name="Predefined Type", default=None) ifc_userdefined_type: StringProperty(name="Userdefined Type") diff --git a/src/ifcopenshell-python/ifcopenshell/util/entity_to_type_map_4.json b/src/ifcopenshell-python/ifcopenshell/util/entity_to_type_map_4.json index 8756097053..b719ad79b4 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/entity_to_type_map_4.json +++ b/src/ifcopenshell-python/ifcopenshell/util/entity_to_type_map_4.json @@ -90,7 +90,8 @@ "IfcDistributionChamberElementType" ], "IfcDoor": [ - "IfcDoorType" + "IfcDoorType", + "IfcDoorStyle" ], "IfcDuctFitting": [ "IfcDuctFittingType" @@ -306,7 +307,8 @@ "IfcWasteTerminalType" ], "IfcWindow": [ - "IfcWindowType" + "IfcWindowType", + "IfcWindowStyle" ], "IfcBeamStandardCase": [ "IfcBeamType" From 074a55b81649d30703e8df3f4c79dcafab059a2a Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 17 Mar 2022 14:10:51 +1100 Subject: [PATCH 23/85] Fix bug where delete confirmation was not consistent with vanilla Blender --- src/blenderbim/blenderbim/bim/module/geometry/operator.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 5660ee76b3..9aeb7c7815 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -355,6 +355,7 @@ class OverrideDeleteTrait: class OverrideDelete(bpy.types.Operator, OverrideDeleteTrait): bl_idname = "object.delete" bl_label = "Delete" + bl_options = {"REGISTER", "UNDO"} use_global: bpy.props.BoolProperty(default=False) confirm: bpy.props.BoolProperty(default=True) @@ -371,7 +372,10 @@ class OverrideDelete(bpy.types.Operator, OverrideDeleteTrait): return {"FINISHED"} def invoke(self, context, event): - return context.window_manager.invoke_confirm(self, event) + if self.confirm: + return context.window_manager.invoke_confirm(self, event) + self.confirm = True + return self.execute(context) def _execute(self, context): for obj in context.selected_objects: @@ -383,6 +387,7 @@ class OverrideDelete(bpy.types.Operator, OverrideDeleteTrait): class OverrideOutlinerDelete(bpy.types.Operator, OverrideDeleteTrait): bl_idname = "outliner.delete" bl_label = "Delete" + bl_options = {"REGISTER", "UNDO"} hierarchy: bpy.props.BoolProperty(default=False) @classmethod @@ -453,6 +458,7 @@ class OverrideOutlinerDelete(bpy.types.Operator, OverrideDeleteTrait): class OverrideDuplicateMove(bpy.types.Operator): bl_idname = "object.duplicate_move" bl_label = "Duplicate Objects" + bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): From 2bfff12988abbb9ef6d2ead370db69e780c9e0a8 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 18 Mar 2022 15:49:56 +1100 Subject: [PATCH 24/85] #1153. Drawing names are now generated based on their location and show an icon based on the drawing type. --- .../blenderbim/bim/module/drawing/operator.py | 8 +++++--- .../blenderbim/bim/module/drawing/prop.py | 1 + src/blenderbim/blenderbim/bim/module/drawing/ui.py | 11 ++++++++++- src/blenderbim/blenderbim/core/drawing.py | 2 +- src/blenderbim/blenderbim/core/tool.py | 1 + src/blenderbim/blenderbim/tool/drawing.py | 14 ++++++++++++++ src/blenderbim/test/tool/test_drawing.py | 3 +++ 7 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/drawing/operator.py b/src/blenderbim/blenderbim/bim/module/drawing/operator.py index 75b026a49a..b15cfc425d 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/operator.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/operator.py @@ -1070,8 +1070,10 @@ class CopyGrid(bpy.types.Operator): return helper.get_active_drawing(context.scene)[0] is not None def execute(self, context): + drawing = tool.Ifc.get_entity(context.scene.camera) + target_view = tool.Drawing.get_drawing_target_view(drawing) subcontext = ifcopenshell.util.representation.get_context( - IfcStore.get_file(), "Plan", "Annotation", context.scene.camera.data.BIMCameraProperties.target_view + IfcStore.get_file(), "Plan", "Annotation", target_view ) if not subcontext: return {"FINISHED"} @@ -1080,8 +1082,8 @@ class CopyGrid(bpy.types.Operator): view_coll, camera = helper.get_active_drawing(context.scene) is_ortho = camera.data.type == "ORTHO" bounds = helper.ortho_view_frame(camera.data) if is_ortho else None - clipping = is_ortho and camera.data.BIMCameraProperties.target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW") - elevating = is_ortho and camera.data.BIMCameraProperties.target_view in ("ELEVATION_VIEW", "SECTION_VIEW") + clipping = is_ortho and target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW") + elevating = is_ortho and target_view in ("ELEVATION_VIEW", "SECTION_VIEW") def grep(coll): results = [] diff --git a/src/blenderbim/blenderbim/bim/module/drawing/prop.py b/src/blenderbim/blenderbim/bim/module/drawing/prop.py index 31f36cfc3f..6e9dadb80e 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/prop.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/prop.py @@ -199,6 +199,7 @@ class Variable(PropertyGroup): class Drawing(PropertyGroup): ifc_definition_id: IntProperty(name="IFC Definition ID") name: StringProperty(name="Name", update=update_drawing_name) + target_view: StringProperty(name="Target View") class Schedule(PropertyGroup): diff --git a/src/blenderbim/blenderbim/bim/module/drawing/ui.py b/src/blenderbim/blenderbim/bim/module/drawing/ui.py index 43ee18a7c1..c68a76c6aa 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/ui.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/ui.py @@ -369,7 +369,16 @@ class BIM_UL_drawinglist(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): if item: row = layout.row(align=True) - row.prop(item, "name", text="", emboss=False) + icon = "UV_FACESEL" + if item.target_view == "ELEVATION_VIEW": + icon = "UV_VERTEXSEL" + elif item.target_view == "SECTION_VIEW": + icon = "UV_EDGESEL" + elif item.target_view == "REFLECTED_PLAN_VIEW": + icon = "XRAY" + elif item.target_view == "MODEL_VIEW": + icon = "SNAP_VOLUME" + row.prop(item, "name", text="", icon=icon, emboss=False) else: layout.label(text="", translate=False) diff --git a/src/blenderbim/blenderbim/core/drawing.py b/src/blenderbim/blenderbim/core/drawing.py index 28a228c1b0..754cf053cd 100644 --- a/src/blenderbim/blenderbim/core/drawing.py +++ b/src/blenderbim/blenderbim/core/drawing.py @@ -99,7 +99,7 @@ def disable_editing_drawings(drawing): def add_drawing(ifc, collector, drawing, target_view=None, location_hint=None): - drawing_name = drawing.ensure_unique_drawing_name("UNTITLED") + drawing_name = drawing.ensure_unique_drawing_name(drawing.generate_drawing_name(target_view, location_hint)) drawing_matrix = drawing.generate_drawing_matrix(target_view, location_hint) camera = drawing.create_camera(drawing_name, drawing_matrix) element = drawing.run_root_assign_class( diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index e37a93a967..dd50034085 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -172,6 +172,7 @@ class Drawing: def ensure_unique_identification(cls, identification): pass def export_text_literal_attributes(cls, obj): pass def generate_drawing_matrix(cls, target_view, location_hint): pass + def generate_drawing_name(cls, target_view, location_hint): pass def generate_sheet_identification(cls): pass def get_annotation_context(cls, target_view): pass def get_body_context(cls): pass diff --git a/src/blenderbim/blenderbim/tool/drawing.py b/src/blenderbim/blenderbim/tool/drawing.py index 8971e3b2d5..173730e817 100644 --- a/src/blenderbim/blenderbim/tool/drawing.py +++ b/src/blenderbim/blenderbim/tool/drawing.py @@ -260,6 +260,7 @@ class Drawing(blenderbim.core.tool.Drawing): new = bpy.context.scene.DocProperties.drawings.add() new.ifc_definition_id = drawing.id() new.name = drawing.Name or "Unnamed" + new.target_view = cls.get_drawing_target_view(drawing) @classmethod def import_sheets(cls): @@ -349,3 +350,16 @@ class Drawing(blenderbim.core.tool.Drawing): for variable in re.findall("{{.*?}}", value): value = value.replace(variable, selector.get_element_value(product, variable[2:-2]) or "") obj.BIMTextProperties.value = value + + # TODO below this point is highly experimental prototype code with no tests + + @classmethod + def generate_drawing_name(cls, target_view, location_hint): + if target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW") and location_hint: + location = tool.Ifc.get().by_id(location_hint) + if target_view == "REFLECTED_PLAN_VIEW": + target_view = "RCP_VIEW" + return (location.Name or "UNNAMED").upper() + " " + target_view.split("_")[0] + elif target_view in ("SECTION_VIEW", "ELEVATION_VIEW") and location_hint: + return location_hint + " " + target_view.split("_")[0] + return target_view diff --git a/src/blenderbim/test/tool/test_drawing.py b/src/blenderbim/test/tool/test_drawing.py index 7680dfff88..91b96f46dd 100644 --- a/src/blenderbim/test/tool/test_drawing.py +++ b/src/blenderbim/test/tool/test_drawing.py @@ -399,10 +399,13 @@ class TestImportDrawings(NewFile): ifc = ifcopenshell.file() tool.Ifc.set(ifc) drawing = ifc.createIfcAnnotation(Name="FOOBAR", ObjectType="DRAWING") + pset = ifcopenshell.api.run("pset.add_pset", ifc, product=drawing, name="EPset_Drawing") + ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"TargetView": "PLAN_VIEW"}) subject.import_drawings() props = bpy.context.scene.DocProperties assert props.drawings[0].ifc_definition_id == drawing.id() assert props.drawings[0].name == "FOOBAR" + assert props.drawings[0].target_view == "PLAN_VIEW" class TestImportSheets(NewFile): From dcc1ed560771fcbf75f9e20fc8129a117c77baed Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 18 Mar 2022 16:00:49 +1100 Subject: [PATCH 25/85] #1153. References to grids in drawings are now auto synchronised when activating a drawing view. --- src/blenderbim/blenderbim/bim/export_ifc.py | 2 + .../blenderbim/bim/module/drawing/operator.py | 1 + src/blenderbim/blenderbim/core/drawing.py | 39 +++++ src/blenderbim/blenderbim/core/tool.py | 5 + src/blenderbim/blenderbim/tool/drawing.py | 159 ++++++++++++++++++ src/blenderbim/blenderbim/tool/ifc.py | 21 +++ .../api/drawing/assign_product.py | 33 +++- 7 files changed, 253 insertions(+), 7 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/export_ifc.py b/src/blenderbim/blenderbim/bim/export_ifc.py index 39bc82df35..729786b7a7 100644 --- a/src/blenderbim/blenderbim/bim/export_ifc.py +++ b/src/blenderbim/blenderbim/bim/export_ifc.py @@ -28,6 +28,7 @@ import ifcopenshell import ifcopenshell.api import ifcopenshell.util.placement import blenderbim.tool as tool +import blenderbim.core.geometry import blenderbim.core.aggregate import blenderbim.core.spatial import blenderbim.core.style @@ -179,6 +180,7 @@ class IfcExporter: self.sync_object_placement(grid_obj) if grid_obj.matrix_world != obj.matrix_world: bpy.ops.bim.update_representation(obj=obj.name) + tool.Geometry.record_object_position(obj) def get_application_name(self): return "BlenderBIM" diff --git a/src/blenderbim/blenderbim/bim/module/drawing/operator.py b/src/blenderbim/blenderbim/bim/module/drawing/operator.py index b15cfc425d..27fa0688fa 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/operator.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/operator.py @@ -678,6 +678,7 @@ class ActivateView(bpy.types.Operator): project_collection.children["Views"].children[camera.users_collection[0].name].hide_viewport = False bpy.data.collections.get(camera.users_collection[0].name).hide_render = False bpy.ops.bim.activate_drawing_style() + core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=tool.Ifc.get().by_id(self.drawing)) return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/core/drawing.py b/src/blenderbim/blenderbim/core/drawing.py index 754cf053cd..f9c058cbea 100644 --- a/src/blenderbim/blenderbim/core/drawing.py +++ b/src/blenderbim/blenderbim/core/drawing.py @@ -158,3 +158,42 @@ def add_annotation(ifc, collector, drawing_tool, drawing=None, object_type=None) ifc.run("group.assign_group", group=drawing_tool.get_drawing_group(drawing), product=element) collector.assign(obj) drawing_tool.enable_editing(obj) + + +def sync_references(ifc, collector, drawing_tool, drawing=None): + context = drawing_tool.get_annotation_context(drawing_tool.get_drawing_target_view(drawing)) + if not context: + return + + group = drawing_tool.get_drawing_group(drawing) + for reference_element in drawing_tool.get_potential_reference_elements(drawing): + reference_obj = ifc.get_object(reference_element) + annotation = drawing_tool.get_drawing_reference_annotation(drawing, reference_element) + + should_delete_existing_annotation = False + should_create_annotation = False + + if annotation and (not reference_obj or ifc.is_moved(reference_obj) or ifc.is_edited(reference_obj)): + should_delete_existing_annotation = True + + if reference_obj and (should_delete_existing_annotation or not annotation): + should_create_annotation = True + + if should_delete_existing_annotation: + annotation_obj = ifc.get_object(annotation) + if annotation_obj: + drawing_tool.delete_object(annotation_obj) + ifc.run("root.remove_product", product=annotation) + + if should_create_annotation: + annotation = drawing_tool.generate_reference_annotation(drawing, reference_element, context) + if annotation: + ifc.run("drawing.assign_product", relating_product=reference_element, related_object=annotation) + ifc.run("group.assign_group", group=group, product=annotation) + collector.assign(ifc.get_object(annotation)) + + if reference_obj and ifc.is_moved(reference_obj): + drawing_tool.sync_object_placement(reference_obj) + + if reference_obj and ifc.is_edited(reference_obj): + drawing_tool.sync_object_representation(reference_obj) diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index dd50034085..5d4471091e 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -159,6 +159,7 @@ class Drawing: def create_svg_sheet(cls, document, titleblock): pass def delete_collection(cls, collection): pass def delete_drawing_elements(cls, elements): pass + def delete_object(cls, obj): pass def disable_editing_drawings(cls): pass def disable_editing_sheets(cls): pass def disable_editing_text(cls, obj): pass @@ -193,6 +194,7 @@ class Drawing: def run_root_assign_class(cls, obj=None, ifc_class=None, predefined_type=None, should_add_representation=True, context=None, ifc_representation_class=None): pass def set_drawing_collection_name(cls, group, collection): pass def show_decorations(cls): pass + def sync_object_placement(cls, obj): pass def update_text_value(cls, obj): pass @@ -247,6 +249,9 @@ class Ifc: def get_entity(cls, obj): pass def get_object(cls, entity): pass def get_schema(cls): pass + def is_deleted(cls, element): pass + def is_edited(cls, obj): pass + def is_moved(cls, obj): pass def link(cls, element, obj): pass def run(cls, command, **kwargs): pass def set(cls, ifc): pass diff --git a/src/blenderbim/blenderbim/tool/drawing.py b/src/blenderbim/blenderbim/tool/drawing.py index 173730e817..95b8fd6d3b 100644 --- a/src/blenderbim/blenderbim/tool/drawing.py +++ b/src/blenderbim/blenderbim/tool/drawing.py @@ -19,13 +19,17 @@ import os import re import bpy +import bmesh import mathutils import webbrowser +import numpy as np import blenderbim.core.tool +import blenderbim.core.geometry import blenderbim.tool as tool import ifcopenshell.util.representation import blenderbim.bim.module.drawing.sheeter as sheeter import blenderbim.bim.module.drawing.annotation as annotation +import blenderbim.bim.module.drawing.helper as helper class Drawing(blenderbim.core.tool.Drawing): @@ -83,6 +87,10 @@ class Drawing(blenderbim.core.tool.Drawing): if obj: bpy.data.objects.remove(obj) + @classmethod + def delete_object(cls, obj): + bpy.data.objects.remove(obj) + @classmethod def disable_editing_drawings(cls): bpy.context.scene.DocProperties.is_editing_drawings = False @@ -363,3 +371,154 @@ class Drawing(blenderbim.core.tool.Drawing): elif target_view in ("SECTION_VIEW", "ELEVATION_VIEW") and location_hint: return location_hint + " " + target_view.split("_")[0] return target_view + + @classmethod + def get_potential_reference_elements(cls, drawing): + elements = [] + existing_references = cls.get_group_elements(cls.get_drawing_group(drawing)) + for element in tool.Ifc.get().by_type("IfcAnnotation"): + if element in existing_references or element == drawing: + continue + if element.ObjectType == "DRAWING": + psets = ifcopenshell.util.element.get_psets(element) + if psets.get("EPset_Drawing", {}).get("TargetView", None) in ("SECTION_VIEW", "ELEVATION_VIEW"): + elements.append(element) + for element in tool.Ifc.get().by_type("IfcGridAxis"): + elements.append(element) + return elements + + @classmethod + def get_drawing_reference_annotation(cls, drawing, reference_element): + if drawing == reference_element: + return True + for element in cls.get_group_elements(cls.get_drawing_group(drawing)): + if element.is_a("IfcAnnotation"): + for rel in element.HasAssignments: + if rel.is_a("IfcRelAssignsToProduct"): + if rel.RelatingProduct == reference_element: + return element + # We cannot associate IfcGridAxis directly, so we establish a convention: + # IfcRelAssignsToProduct.RelatingProduct = IfcGrid + # IfcRelAssignsToProduct.Name = IfcGridAxis.AxisTag + elif reference_element.is_a("IfcGridAxis") and rel.Name == reference_element.AxisTag: + return element + + @classmethod + def generate_reference_annotation(cls, drawing, reference_element, context): + if reference_element.is_a("IfcGridAxis"): + return cls.generate_grid_axis_reference_annotation(drawing, reference_element, context) + + @classmethod + def generate_grid_axis_reference_annotation(cls, drawing, reference_element, context): + target_view = tool.Drawing.get_drawing_target_view(drawing) + + camera = tool.Ifc.get_object(drawing) + + is_ortho = camera.data.type == "ORTHO" + bounds = helper.ortho_view_frame(camera.data) if is_ortho else None + clipping = is_ortho and target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW") + elevating = is_ortho and target_view in ("ELEVATION_VIEW", "SECTION_VIEW") + + def clone(src): + dst = src.copy() + dst.data = dst.data.copy() + dst.name = dst.name.replace("IfcGridAxis/", "") + dst.BIMObjectProperties.ifc_definition_id = 0 + dst.data.BIMMeshProperties.ifc_definition_id = 0 + return dst + + def disassemble(obj): + mesh = bmesh.new() + mesh.verts.ensure_lookup_table() + mesh.from_mesh(obj.data) + return obj, mesh + + def assemble(obj, mesh): + mesh.to_mesh(obj.data) + return obj + + def to_camera_coords(obj, mesh): + mesh.transform(camera.matrix_world.inverted() @ obj.matrix_world) + obj.matrix_world = camera.matrix_world + annotation_offset = mathutils.Vector((0, 0, -camera.data.clip_start)) + annotation_offset = camera.matrix_world.to_quaternion() @ annotation_offset + obj.matrix_world[0][3] += annotation_offset[0] + obj.matrix_world[1][3] += annotation_offset[1] + obj.matrix_world[2][3] += annotation_offset[2] + return obj, mesh + + def clip_to_camera_boundary(mesh): + mesh.verts.ensure_lookup_table() + points = [v.co for v in mesh.verts[0:2]] + points = helper.clip_segment(bounds, points) + if points is None: + return None + mesh.verts[0].co = points[0] + mesh.verts[1].co = points[1] + return mesh + + def draw_grids_vertically(mesh): + mesh.verts.ensure_lookup_table() + points = [v.co for v in mesh.verts[0:2]] + points = helper.elevate_segment(bounds, points) + if points is None: + return None + points = helper.clip_segment(bounds, points) + if points is None: + return None + mesh.verts[0].co = points[0] + mesh.verts[1].co = points[1] + return mesh + + obj = tool.Ifc.get_object(reference_element) + if not obj: + return + obj, mesh = to_camera_coords(*disassemble(clone(obj))) + + if clipping: + mesh = clip_to_camera_boundary(mesh) + elif elevating: + mesh = draw_grids_vertically(mesh) + + if mesh is None: + return + + assemble(obj, mesh) + + element = cls.run_root_assign_class( + obj=obj, + ifc_class="IfcAnnotation", + predefined_type="GRID", + should_add_representation=True, + context=context, + ifc_representation_class=None, + ) + return element + + @classmethod + def sync_object_representation(cls, obj): + bpy.ops.bim.update_representation(obj=obj.name) + + @classmethod + def sync_object_placement(cls, obj): + blender_matrix = np.array(obj.matrix_world) + element = tool.Ifc.get_entity(obj) + if (obj.scale - mathutils.Vector((1.0, 1.0, 1.0))).length > 1e-4: + bpy.ops.bim.update_representation(obj=obj.name) + return element + if element.is_a("IfcGridAxis"): + return cls.sync_grid_axis_object_placement(obj, element) + if not hasattr(element, "ObjectPlacement"): + return + blenderbim.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + return element + + @classmethod + def sync_grid_axis_object_placement(cls, obj, element): + grid = (element.PartOfU or element.PartOfV or element.PartOfW)[0] + grid_obj = tool.Ifc.get_object(grid) + if grid_obj: + cls.sync_object_placement(grid_obj) + if grid_obj.matrix_world != obj.matrix_world: + bpy.ops.bim.update_representation(obj=obj.name) + tool.Geometry.record_object_position(obj) diff --git a/src/blenderbim/blenderbim/tool/ifc.py b/src/blenderbim/blenderbim/tool/ifc.py index ad719f9c55..d487aafbcb 100644 --- a/src/blenderbim/blenderbim/tool/ifc.py +++ b/src/blenderbim/blenderbim/tool/ifc.py @@ -17,6 +17,7 @@ # along with BlenderBIM Add-on. If not, see . import bpy +import numpy as np import ifcopenshell.api import blenderbim.core.tool from blenderbim.bim.ifc import IfcStore @@ -44,6 +45,26 @@ class Ifc(blenderbim.core.tool.Ifc): if IfcStore.get_file(): return IfcStore.get_file().schema + @classmethod + def is_deleted(cls, element): + return element.id() in IfcStore.deleted_ids + + @classmethod + def is_edited(cls, obj): + return list(obj.scale) != [1.0, 1.0, 1.0] or obj in IfcStore.edited_objs + + @classmethod + def is_moved(cls, obj): + if not obj.BIMObjectProperties.location_checksum: + return True # Let's be conservative + loc_check = np.frombuffer(eval(obj.BIMObjectProperties.location_checksum)) + rot_check = np.frombuffer(eval(obj.BIMObjectProperties.rotation_checksum)) + loc_real = np.array(obj.matrix_world.translation).flatten() + rot_real = np.array(obj.matrix_world.to_3x3()).flatten() + if np.allclose(loc_check, loc_real, atol=1e-4) and np.allclose(rot_check, rot_real, atol=1e-2): + return False + return True + @classmethod def schema(cls): return IfcStore.get_schema() diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/assign_product.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/assign_product.py index 913a82e8e5..b41ce3865b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/assign_product.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/assign_product.py @@ -31,16 +31,32 @@ class Usecase: self.settings[key] = value def execute(self): - if self.settings["related_object"].HasAssignments: - for assignment in self.settings["related_object"].HasAssignments: - if ( - assignment.is_a("IfcRelAssignsToProduct") - and assignment.RelatingProduct == self.settings["relating_product"] - ): + is_grid_axis = self.settings["relating_product"].is_a("IfcGridAxis") + + if is_grid_axis: + if self.settings["related_object"].HasAssignments: + for rel in self.settings["related_object"].HasAssignments: + if rel.is_a("IfcRelAssignsToProduct") and rel.Name == self.settings["relating_product"].AxisTag: + return + elif self.settings["related_object"].HasAssignments: + for rel in self.settings["related_object"].HasAssignments: + if rel.is_a("IfcRelAssignsToProduct") and rel.RelatingProduct == self.settings["relating_product"]: return referenced_by = None - if self.settings["relating_product"].ReferencedBy: + + if is_grid_axis: + axis = self.settings["relating_product"] + grid = None + for attribute in ("PartOfW", "PartOfV", "PartOfU"): + if getattr(axis, attribute, None): + grid = getattr(axis, attribute)[0] + self.settings["relating_product"] = grid + for rel in grid.ReferencedBy: + if rel.Name == axis.AxisTag: + referenced_by = rel + break + elif self.settings["relating_product"].ReferencedBy: referenced_by = self.settings["relating_product"].ReferencedBy[0] if referenced_by: @@ -58,4 +74,7 @@ class Usecase: "RelatingProduct": self.settings["relating_product"], } ) + + if is_grid_axis: + referenced_by.Name = axis.AxisTag return referenced_by From b8aa90de007ecf50fa2ac9dd7e22abe5cf9ef7d8 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 18 Mar 2022 17:24:59 +1100 Subject: [PATCH 26/85] #2094. New rudimentary project materials browser to manage materials. --- .../blenderbim/bim/module/document/ui.py | 2 +- .../bim/module/material/__init__.py | 8 +++ .../blenderbim/bim/module/material/data.py | 63 +++++++++++++++++++ .../bim/module/material/operator.py | 25 +++++--- .../blenderbim/bim/module/material/prop.py | 19 ++++++ .../blenderbim/bim/module/material/ui.py | 42 ++++++++++++- src/blenderbim/blenderbim/bim/ui.py | 2 +- src/blenderbim/blenderbim/core/material.py | 9 +++ src/blenderbim/blenderbim/core/tool.py | 3 + src/blenderbim/blenderbim/tool/material.py | 22 +++++++ .../test/bim/feature/material.feature | 13 ++++ src/blenderbim/test/core/test_drawing.py | 3 +- src/blenderbim/test/core/test_material.py | 13 ++++ src/blenderbim/test/tool/test_material.py | 61 ++++++++++++++++++ 14 files changed, 274 insertions(+), 11 deletions(-) create mode 100644 src/blenderbim/blenderbim/bim/module/material/data.py diff --git a/src/blenderbim/blenderbim/bim/module/document/ui.py b/src/blenderbim/blenderbim/bim/module/document/ui.py index ba1b4704b4..b5ee5efe93 100644 --- a/src/blenderbim/blenderbim/bim/module/document/ui.py +++ b/src/blenderbim/blenderbim/bim/module/document/ui.py @@ -29,7 +29,7 @@ class BIM_PT_documents(Panel): bl_space_type = "PROPERTIES" bl_region_type = "WINDOW" bl_context = "scene" - bl_parent_id = "BIM_PT_collaboration" + bl_parent_id = "BIM_PT_project_setup" @classmethod def poll(cls, context): diff --git a/src/blenderbim/blenderbim/bim/module/material/__init__.py b/src/blenderbim/blenderbim/bim/module/material/__init__.py index dc45495d16..0c42d9c04d 100644 --- a/src/blenderbim/blenderbim/bim/module/material/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/material/__init__.py @@ -31,10 +31,12 @@ classes = ( operator.CopyMaterial, operator.DisableEditingAssignedMaterial, operator.DisableEditingMaterialSetItem, + operator.DisableEditingMaterials, operator.EditAssignedMaterial, operator.EditMaterialSetItem, operator.EnableEditingAssignedMaterial, operator.EnableEditingMaterialSetItem, + operator.LoadMaterials, operator.RemoveConstituent, operator.RemoveLayer, operator.RemoveListItem, @@ -43,15 +45,21 @@ classes = ( operator.ReorderMaterialSetItem, operator.UnassignMaterial, operator.UnlinkMaterial, + prop.Material, + prop.BIMMaterialProperties, prop.BIMObjectMaterialProperties, + ui.BIM_PT_materials, ui.BIM_PT_material, ui.BIM_PT_object_material, + ui.BIM_UL_materials, ) def register(): + bpy.types.Scene.BIMMaterialProperties = bpy.props.PointerProperty(type=prop.BIMMaterialProperties) bpy.types.Object.BIMObjectMaterialProperties = bpy.props.PointerProperty(type=prop.BIMObjectMaterialProperties) def unregister(): + del bpy.types.Scene.BIMMaterialProperties del bpy.types.Object.BIMObjectMaterialProperties diff --git a/src/blenderbim/blenderbim/bim/module/material/data.py b/src/blenderbim/blenderbim/bim/module/material/data.py new file mode 100644 index 0000000000..9d758e685a --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/material/data.py @@ -0,0 +1,63 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2022 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BlenderBIM Add-on is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BlenderBIM Add-on. If not, see . + +import os +import bpy +import ifcopenshell +import ifcopenshell.util.schema +import blenderbim.tool as tool + + +def refresh(): + MaterialsData.is_loaded = False + + +class MaterialsData: + data = {} + is_loaded = False + + @classmethod + def load(cls): + cls.data = { + "total_materials": cls.total_materials(), + "material_types": cls.material_types(), + } + cls.is_loaded = True + + @classmethod + def total_materials(cls): + return ( + len(tool.Ifc.get().by_type("IfcMaterial")) + + len(tool.Ifc.get().by_type("IfcMaterialConstituentSet")) + + len(tool.Ifc.get().by_type("IfcMaterialLayerSet")) + + len(tool.Ifc.get().by_type("IfcMaterialProfileSet")) + + len(tool.Ifc.get().by_type("IfcMaterialList")) + ) + + @classmethod + def material_types(cls): + material_types = [ + "IfcMaterial", + "IfcMaterialConstituentSet", + "IfcMaterialLayerSet", + "IfcMaterialProfileSet", + "IfcMaterialList", + ] + if tool.Ifc.get_schema() == "IFC2X3": + material_types = ["IfcMaterial", "IfcMaterialLayerSet", "IfcMaterialList"] + return [(m, m, "") for m in material_types] diff --git a/src/blenderbim/blenderbim/bim/module/material/operator.py b/src/blenderbim/blenderbim/bim/module/material/operator.py index 8fc169ff0e..4fa6685123 100644 --- a/src/blenderbim/blenderbim/bim/module/material/operator.py +++ b/src/blenderbim/blenderbim/bim/module/material/operator.py @@ -31,11 +31,22 @@ from ifcopenshell.api.material.data import Data from ifcopenshell.api.profile.data import Data as ProfileData -class Operator: - def execute(self, context): - IfcStore.execute_ifc_operator(self, context) - blenderbim.bim.handler.refresh_ui_data() - return {"FINISHED"} +class LoadMaterials(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.load_materials" + bl_label = "Load Materials" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + core.load_materials(tool.Material, context.scene.BIMMaterialProperties.material_type) + + +class DisableEditingMaterials(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.disable_editing_materials" + bl_label = "Disable Editing Materials" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + core.disable_editing_materials(tool.Material) class AssignParameterizedProfile(bpy.types.Operator): @@ -68,7 +79,7 @@ class AssignParameterizedProfile(bpy.types.Operator): return {"FINISHED"} -class AddDefaultMaterial(bpy.types.Operator, Operator): +class AddDefaultMaterial(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_default_material" bl_label = "Add Default Material" bl_options = {"REGISTER", "UNDO"} @@ -131,7 +142,7 @@ class RemoveMaterial(bpy.types.Operator): return {"FINISHED"} -class UnlinkMaterial(bpy.types.Operator, Operator): +class UnlinkMaterial(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unlink_material" bl_label = "Unlink Material" bl_options = {"REGISTER", "UNDO"} diff --git a/src/blenderbim/blenderbim/bim/module/material/prop.py b/src/blenderbim/blenderbim/bim/module/material/prop.py index 660303d471..9789dd18e7 100644 --- a/src/blenderbim/blenderbim/bim/module/material/prop.py +++ b/src/blenderbim/blenderbim/bim/module/material/prop.py @@ -18,6 +18,7 @@ import bpy from ifcopenshell.api.material.data import Data +from blenderbim.bim.module.material.data import MaterialsData from blenderbim.bim.ifc import IfcStore from blenderbim.bim.prop import StrProperty, Attribute from bpy.types import PropertyGroup @@ -104,6 +105,24 @@ def getMaterialTypes(self, context): return materialtypes_enum +def get_material_types(self, context): + if not MaterialsData.is_loaded: + MaterialsData.load() + return MaterialsData.data["material_types"] + + +class Material(PropertyGroup): + name: StringProperty(name="Name") + ifc_definition_id: IntProperty(name="IFC Definition ID") + + +class BIMMaterialProperties(PropertyGroup): + is_editing: BoolProperty(name="Is Editing", default=False) + material_type: EnumProperty(items=get_material_types, name="Material Type") + materials: CollectionProperty(name="Materials", type=Material) + active_material_index: IntProperty(name="Active Material Index") + + class BIMObjectMaterialProperties(PropertyGroup): material_type: EnumProperty(items=getMaterialTypes, name="Material Type") material: EnumProperty(items=getMaterials, name="Material") diff --git a/src/blenderbim/blenderbim/bim/module/material/ui.py b/src/blenderbim/blenderbim/bim/module/material/ui.py index f7f51e2f03..24f345a125 100644 --- a/src/blenderbim/blenderbim/bim/module/material/ui.py +++ b/src/blenderbim/blenderbim/bim/module/material/ui.py @@ -17,11 +17,44 @@ # along with BlenderBIM Add-on. If not, see . import blenderbim.bim.helper -from bpy.types import Panel +from bpy.types import Panel, UIList from ifcopenshell.api.material.data import Data from ifcopenshell.api.profile.data import Data as ProfileData from blenderbim.bim.ifc import IfcStore from blenderbim.bim.helper import draw_attributes +from blenderbim.bim.module.material.data import MaterialsData + + +class BIM_PT_materials(Panel): + bl_label = "IFC Materials" + bl_idname = "BIM_PT_materials" + bl_options = {"DEFAULT_CLOSED"} + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_geometry" + + @classmethod + def poll(cls, context): + return IfcStore.get_file() + + def draw(self, context): + if not MaterialsData.is_loaded: + MaterialsData.load() + + self.props = context.scene.BIMMaterialProperties + + row = self.layout.row(align=True) + row.label(text="{} Materials Found".format(MaterialsData.data["total_materials"]), icon="MATERIAL") + if self.props.is_editing: + row.operator("bim.disable_editing_materials", text="", icon="CANCEL") + else: + row = self.layout.row(align=True) + row.prop(self.props, "material_type", text="") + row.operator("bim.load_materials", text="", icon="IMPORT") + return + + self.layout.template_list("BIM_UL_materials", "", self.props, "materials", self.props, "active_material_index") class BIM_PT_material(Panel): @@ -333,3 +366,10 @@ class BIM_PT_object_material(Panel): if total_thickness: row = self.layout.row(align=True) row.label(text=f"Total Thickness: {total_thickness:.3f}") + + +class BIM_UL_materials(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + if item: + row = layout.row(align=True) + row.label(text=item.name) diff --git a/src/blenderbim/blenderbim/bim/ui.py b/src/blenderbim/blenderbim/bim/ui.py index 83b2ba536d..000ceac13c 100644 --- a/src/blenderbim/blenderbim/bim/ui.py +++ b/src/blenderbim/blenderbim/bim/ui.py @@ -233,7 +233,7 @@ class BIM_PT_geometry(Panel): class BIM_PT_4D5D(Panel): - bl_label = "IFC 4D/5D" + bl_label = "IFC Costing and Scheduling" bl_space_type = "PROPERTIES" bl_region_type = "WINDOW" bl_context = "scene" diff --git a/src/blenderbim/blenderbim/core/material.py b/src/blenderbim/blenderbim/core/material.py index 88c6405e2b..ce2f6466dc 100644 --- a/src/blenderbim/blenderbim/core/material.py +++ b/src/blenderbim/blenderbim/core/material.py @@ -25,3 +25,12 @@ def add_default_material(ifc, material): obj = material.add_default_material_object() ifc.link(ifc.run("material.add_material", name="Default"), obj) return obj + + +def load_materials(material, material_type): + material.import_material_definitions(material_type) + material.enable_editing_materials() + + +def disable_editing_materials(material): + material.disable_editing_materials() diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index 5d4471091e..f4a9450609 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -276,6 +276,9 @@ class Library: @interface class Material: def add_default_material_object(cls): pass + def disable_editing_materials(cls): pass + def enable_editing_materials(cls): pass + def import_material_definitions(cls, material_type): pass @interface diff --git a/src/blenderbim/blenderbim/tool/material.py b/src/blenderbim/blenderbim/tool/material.py index 1acb411b62..0c965347fe 100644 --- a/src/blenderbim/blenderbim/tool/material.py +++ b/src/blenderbim/blenderbim/tool/material.py @@ -27,3 +27,25 @@ class Material(blenderbim.core.tool.Material): @classmethod def add_default_material_object(cls): return bpy.data.materials.new("Default") + + @classmethod + def disable_editing_materials(cls): + bpy.context.scene.BIMMaterialProperties.is_editing = False + + @classmethod + def enable_editing_materials(cls): + bpy.context.scene.BIMMaterialProperties.is_editing = True + + @classmethod + def import_material_definitions(cls, material_type): + props = bpy.context.scene.BIMMaterialProperties + props.materials.clear() + for material in tool.Ifc.get().by_type(material_type): + new = props.materials.add() + new.ifc_definition_id = material.id() + if material.is_a("IfcMaterialLayerSet"): + new.name = material.LayerSetName or "Unnamed" + elif material.is_a("IfcMaterialList"): + new.name = "Unnamed" + else: + new.name = material.Name or "Unnamed" diff --git a/src/blenderbim/test/bim/feature/material.feature b/src/blenderbim/test/bim/feature/material.feature index 6516cb5c9a..4dafefaec2 100644 --- a/src/blenderbim/test/bim/feature/material.feature +++ b/src/blenderbim/test/bim/feature/material.feature @@ -1,6 +1,19 @@ @material Feature: Material +Scenario: Load materials + Given an empty IFC project + And I press "bim.add_default_material" + When I press "bim.load_materials" + Then nothing happens + +Scenario: Disable editing materials + Given an empty IFC project + And I press "bim.add_default_material" + And I press "bim.load_materials" + When I press "bim.disable_editing_materials" + Then nothing happens + Scenario: Add default material Given an empty IFC project When I press "bim.add_default_material" diff --git a/src/blenderbim/test/core/test_drawing.py b/src/blenderbim/test/core/test_drawing.py index 5fb7b822fa..a18d3fa140 100644 --- a/src/blenderbim/test/core/test_drawing.py +++ b/src/blenderbim/test/core/test_drawing.py @@ -141,7 +141,8 @@ class TestDisableEditingDrawings: class TestAddDrawing: def test_run(self, ifc, collector, drawing): - drawing.ensure_unique_drawing_name("UNTITLED").should_be_called().will_return("name") + drawing.generate_drawing_name("target_view", "location_hint").should_be_called().will_return("drawing_name") + drawing.ensure_unique_drawing_name("drawing_name").should_be_called().will_return("name") drawing.generate_drawing_matrix("target_view", "location_hint").should_be_called().will_return("matrix") drawing.create_camera("name", "matrix").should_be_called().will_return("obj") drawing.get_body_context().should_be_called().will_return("context") diff --git a/src/blenderbim/test/core/test_material.py b/src/blenderbim/test/core/test_material.py index a35a2fbf6e..86404c1aa6 100644 --- a/src/blenderbim/test/core/test_material.py +++ b/src/blenderbim/test/core/test_material.py @@ -32,3 +32,16 @@ class TestAddDefaultMaterial: ifc.run("material.add_material", name="Default").should_be_called().will_return("material") ifc.link("material", "obj").should_be_called() assert subject.add_default_material(ifc, material) == "obj" + + +class TestLoadMaterials: + def test_run(self, material): + material.import_material_definitions("material_type").should_be_called() + material.enable_editing_materials().should_be_called() + subject.load_materials(material, "material_type") + + +class TestDisableEditingMaterials: + def test_run(self, material): + material.disable_editing_materials().should_be_called() + subject.disable_editing_materials(material) diff --git a/src/blenderbim/test/tool/test_material.py b/src/blenderbim/test/tool/test_material.py index d8c50c09bf..bd739a864e 100644 --- a/src/blenderbim/test/tool/test_material.py +++ b/src/blenderbim/test/tool/test_material.py @@ -34,3 +34,64 @@ class TestAddDefaultMaterialObject(NewFile): material = subject.add_default_material_object() assert isinstance(material, bpy.types.Material) assert material.name == "Default" + + +class TestDisableEditingMaterials(NewFile): + def test_run(self): + bpy.context.scene.BIMMaterialProperties.is_editing = True + subject.disable_editing_materials() + assert bpy.context.scene.BIMMaterialProperties.is_editing is False + + +class TestEnableEditingMaterials(NewFile): + def test_run(self): + bpy.context.scene.BIMMaterialProperties.is_editing = False + subject.enable_editing_materials() + assert bpy.context.scene.BIMMaterialProperties.is_editing is True + + +class TestImportMaterialDefinitions(NewFile): + def test_import_materials(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + material = ifc.createIfcMaterial(Name="Name") + subject.import_material_definitions("IfcMaterial") + props = bpy.context.scene.BIMMaterialProperties + assert props.materials[0].ifc_definition_id == material.id() + assert props.materials[0].name == "Name" + + def test_import_material_layer_sets(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + material = ifc.createIfcMaterialLayerSet(LayerSetName="Name") + subject.import_material_definitions("IfcMaterialLayerSet") + props = bpy.context.scene.BIMMaterialProperties + assert props.materials[0].ifc_definition_id == material.id() + assert props.materials[0].name == "Name" + + def test_import_material_profile_sets(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + material = ifc.createIfcMaterialProfileSet(Name="Name") + subject.import_material_definitions("IfcMaterialProfileSet") + props = bpy.context.scene.BIMMaterialProperties + assert props.materials[0].ifc_definition_id == material.id() + assert props.materials[0].name == "Name" + + def test_import_material_constituent_sets(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + material = ifc.createIfcMaterialConstituentSet(Name="Name") + subject.import_material_definitions("IfcMaterialConstituentSet") + props = bpy.context.scene.BIMMaterialProperties + assert props.materials[0].ifc_definition_id == material.id() + assert props.materials[0].name == "Name" + + def test_import_material_lists(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + material = ifc.createIfcMaterialList() + subject.import_material_definitions("IfcMaterialList") + props = bpy.context.scene.BIMMaterialProperties + assert props.materials[0].ifc_definition_id == material.id() + assert props.materials[0].name == "Unnamed" From 3fa8fce9946a4cb903c05473422d89d164c7c096 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 19 Mar 2022 17:27:45 +1100 Subject: [PATCH 27/85] Fix bug where gizmo still shown after deleting objects --- src/blenderbim/blenderbim/bim/module/geometry/operator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 9aeb7c7815..5a6bdd8325 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -369,6 +369,8 @@ class OverrideDelete(bpy.types.Operator, OverrideDeleteTrait): return IfcStore.execute_ifc_operator(self, context) for obj in context.selected_objects: bpy.data.objects.remove(obj) + # Required otherwise gizmos are still visible + context.view_layer.objects.active = None return {"FINISHED"} def invoke(self, context, event): @@ -381,6 +383,8 @@ class OverrideDelete(bpy.types.Operator, OverrideDeleteTrait): for obj in context.selected_objects: self.delete_ifc_object(obj) bpy.data.objects.remove(obj) + # Required otherwise gizmos are still visible + context.view_layer.objects.active = None return {"FINISHED"} @@ -410,7 +414,6 @@ class OverrideOutlinerDelete(bpy.types.Operator, OverrideDeleteTrait): if item.bl_rna.identifier == "Collection": collection = bpy.data.collections.get(item.name) collection_data = self.get_collection_objects_and_children(collection) - print(collection_data) objects_to_delete |= collection_data["objects"] collections_to_delete |= collection_data["children"] collections_to_delete.add(collection) From 8fa5873e7e2aaef34e3f54fd01ed2ff15a5ada20 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sat, 19 Mar 2022 10:49:51 +0100 Subject: [PATCH 28/85] #2095 take into account face-face distance on operand a --- src/ifcgeom/IfcGeomFunctions.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ifcgeom/IfcGeomFunctions.cpp b/src/ifcgeom/IfcGeomFunctions.cpp index 69e74a8102..ce4c94a6dc 100644 --- a/src/ifcgeom/IfcGeomFunctions.cpp +++ b/src/ifcgeom/IfcGeomFunctions.cpp @@ -5224,8 +5224,11 @@ bool IfcGeom::Kernel::boolean_operation(const TopoDS_Shape& a_input, const TopTo PERF("boolean operation: result min face-face dist check"); if ((v = min_face_face_distance(r, 1.e-4)) < 1.e-4) { - reason = 2; - success = false; + // #2095 Check if this distance wasn't already realized in the input first operand. + if (v < min_face_face_distance(a, 1.e-4)) { + reason = 2; + success = false; + } } } From d45a439f6c4873c9340a4a5dc509ed7614396ecd Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sat, 19 Mar 2022 11:36:30 +0100 Subject: [PATCH 29/85] todo note --- src/ifcgeom/IfcGeomFunctions.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ifcgeom/IfcGeomFunctions.cpp b/src/ifcgeom/IfcGeomFunctions.cpp index ce4c94a6dc..88e2bf132a 100644 --- a/src/ifcgeom/IfcGeomFunctions.cpp +++ b/src/ifcgeom/IfcGeomFunctions.cpp @@ -4240,6 +4240,7 @@ bool IfcGeom::Kernel::wire_intersections(const TopoDS_Wire& wire, TopTools_ListO : (std::min)(min_edge_length(wire) / 2., getValue(GV_PRECISION) * 10.); } + // @todo: should this start from 0 in case of n > 64? for (int i = 2; i < n; ++i) { std::vector js; From 2049bdd976fd764fda77760b4c2e5806dc1f005c Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sat, 19 Mar 2022 11:36:38 +0100 Subject: [PATCH 30/85] todo note --- src/ifcgeom/IfcGeomWires.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ifcgeom/IfcGeomWires.cpp b/src/ifcgeom/IfcGeomWires.cpp index 93beea10d7..003ebe1af0 100644 --- a/src/ifcgeom/IfcGeomWires.cpp +++ b/src/ifcgeom/IfcGeomWires.cpp @@ -681,6 +681,7 @@ bool IfcGeom::Kernel::convert(const IfcSchema::IfcPolyline* l, TopoDS_Wire& resu polygon.Append(pnt); } + // @todo the strict tolerance should also govern these arbitrary precision increases const double eps = getValue(GV_PRECISION) * 10; const bool closed_by_proximity = polygon.Length() >= 3 && polygon.First().Distance(polygon.Last()) < eps; if (closed_by_proximity) { From fdd03d7294821825525c15a23f5827bf33d058b3 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 19 Mar 2022 22:11:39 +1100 Subject: [PATCH 31/85] #2094. Removing materials now removes material definitions too for styles --- .../api/material/add_material_set.py | 32 +++++++ .../api/material/assign_material.py | 2 +- .../api/material/remove_material.py | 10 +- .../api/material/test_add_material_set.py | 41 +++++++++ .../test/api/material/test_remove_material.py | 91 +++++++++++++++++++ 5 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 src/ifcopenshell-python/ifcopenshell/api/material/add_material_set.py create mode 100644 src/ifcopenshell-python/test/api/material/test_add_material_set.py create mode 100644 src/ifcopenshell-python/test/api/material/test_remove_material.py diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/add_material_set.py b/src/ifcopenshell-python/ifcopenshell/api/material/add_material_set.py new file mode 100644 index 0000000000..91bc609bf3 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/material/add_material_set.py @@ -0,0 +1,32 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + + +class Usecase: + def __init__(self, file, **settings): + self.file = file + self.settings = {"name": "Unnamed", "set_type": "IfcMaterialConstituentSet"} + for key, value in settings.items(): + self.settings[key] = value + + def execute(self): + if self.settings["set_type"] == "IfcMaterialLayerSet": + return self.file.create_entity("IfcMaterialLayerSet", LayerSetName=self.settings["name"] or "Unnamed") + elif self.settings["set_type"] == "IfcMaterialList": + return self.file.create_entity("IfcMaterialList") + return self.file.create_entity(self.settings["set_type"], Name=self.settings["name"] or "Unnamed") diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/assign_material.py b/src/ifcopenshell-python/ifcopenshell/api/material/assign_material.py index a5db198ddd..4430c2eb63 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/assign_material.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/assign_material.py @@ -124,7 +124,7 @@ class Usecase: ) def get_rel_associates_material(self, material): - if self.file.schema == "IFC2X3": + if self.file.schema == "IFC2X3" or material.is_a("IfcMaterialList"): rel = [ r for r in self.file.by_type("IfcRelAssociatesMaterial") diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/remove_material.py b/src/ifcopenshell-python/ifcopenshell/api/material/remove_material.py index 8213bdd933..42bc5c5fd9 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/remove_material.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/remove_material.py @@ -29,8 +29,8 @@ class Usecase: def execute(self): inverse_elements = self.file.get_inverse(self.settings["material"]) self.file.remove(self.settings["material"]) - # TODO: this is probably not robust enough - # TODO: purge material definition representation + # TODO: Right now, we we choose only to delete set items (e.g. a layer) but not the material set + # This can lead to invalid material sets, but we assume the user will deal with it for inverse in inverse_elements: if inverse.is_a("IfcMaterialConstituent"): self.file.remove(inverse) @@ -40,3 +40,9 @@ class Usecase: self.file.remove(inverse) elif inverse.is_a("IfcRelAssociatesMaterial"): self.file.remove(inverse) + elif inverse.is_a("IfcMaterialDefinitionRepresentation"): + for representation in inverse.Representations: + for item in representation.Items: + self.file.remove(item) + self.file.remove(representation) + self.file.remove(inverse) diff --git a/src/ifcopenshell-python/test/api/material/test_add_material_set.py b/src/ifcopenshell-python/test/api/material/test_add_material_set.py new file mode 100644 index 0000000000..7adb706f08 --- /dev/null +++ b/src/ifcopenshell-python/test/api/material/test_add_material_set.py @@ -0,0 +1,41 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import test.bootstrap +import ifcopenshell.api + + +class TestAddMaterialSet(test.bootstrap.IFC4): + def test_add_layer_set(self): + material = ifcopenshell.api.run("material.add_material_set", self.file, set_type="IfcMaterialLayerSet") + assert material.LayerSetName == "Unnamed" + assert material.is_a("IfcMaterialLayerSet") + + def test_add_profile_set(self): + material = ifcopenshell.api.run("material.add_material_set", self.file, set_type="IfcMaterialProfileSet") + assert material.Name == "Unnamed" + assert material.is_a("IfcMaterialProfileSet") + + def test_add_constituent_set(self): + material = ifcopenshell.api.run("material.add_material_set", self.file, set_type="IfcMaterialConstituentSet") + assert material.Name == "Unnamed" + assert material.is_a("IfcMaterialConstituentSet") + + def test_add_list(self): + material = ifcopenshell.api.run("material.add_material_set", self.file, set_type="IfcMaterialList") + assert material.is_a("IfcMaterialList") diff --git a/src/ifcopenshell-python/test/api/material/test_remove_material.py b/src/ifcopenshell-python/test/api/material/test_remove_material.py new file mode 100644 index 0000000000..9d24f0a04d --- /dev/null +++ b/src/ifcopenshell-python/test/api/material/test_remove_material.py @@ -0,0 +1,91 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import test.bootstrap +import ifcopenshell.api + + +class TestRemoveMaterial(test.bootstrap.IFC4): + def test_removing_material(self): + material = ifcopenshell.api.run("material.add_material", self.file) + ifcopenshell.api.run("material.remove_material", self.file, material=material) + assert len(self.file.by_type("IfcMaterial")) == 0 + + def test_removing_material_with_associations(self): + wall = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + material = ifcopenshell.api.run("material.add_material", self.file) + ifcopenshell.api.run("material.assign_material", self.file, product=wall, material=material) + ifcopenshell.api.run("material.remove_material", self.file, material=material) + assert len(self.file.by_type("IfcMaterial")) == 0 + assert len(self.file.by_type("IfcRelAssociatesMaterial")) == 0 + + def test_removing_material_in_layer(self): + wall = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + material = ifcopenshell.api.run("material.add_material", self.file) + material_set = ifcopenshell.api.run( + "material.add_material_set", self.file, set_type="IfcMaterialLayerSet" + ) + ifcopenshell.api.run("material.add_layer", self.file, layer_set=material_set, material=material) + ifcopenshell.api.run("material.assign_material", self.file, product=wall, material=material_set) + assert len(self.file.by_type("IfcMaterialLayerSet")[0].MaterialLayers) == 1 + ifcopenshell.api.run("material.remove_material", self.file, material=material) + assert len(self.file.by_type("IfcMaterial")) == 0 + assert len(self.file.by_type("IfcRelAssociatesMaterial")) == 1 + assert len(self.file.by_type("IfcMaterialLayerSet")[0].MaterialLayers) == 0 + + def test_removing_material_in_profile(self): + wall = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + material = ifcopenshell.api.run("material.add_material", self.file) + material_set = ifcopenshell.api.run( + "material.add_material_set", self.file, set_type="IfcMaterialProfileSet" + ) + ifcopenshell.api.run("material.add_profile", self.file, profile_set=material_set, material=material) + ifcopenshell.api.run("material.assign_material", self.file, product=wall, material=material_set) + assert len(self.file.by_type("IfcMaterialProfileSet")[0].MaterialProfiles) == 1 + ifcopenshell.api.run("material.remove_material", self.file, material=material) + assert len(self.file.by_type("IfcMaterial")) == 0 + assert len(self.file.by_type("IfcRelAssociatesMaterial")) == 1 + assert len(self.file.by_type("IfcMaterialProfileSet")[0].MaterialProfiles) == 0 + + def test_removing_material_in_constituent(self): + wall = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + material = ifcopenshell.api.run("material.add_material", self.file) + material_set = ifcopenshell.api.run( + "material.add_material_set", self.file, set_type="IfcMaterialConstituentSet" + ) + ifcopenshell.api.run("material.add_constituent", self.file, constituent_set=material_set, material=material) + ifcopenshell.api.run("material.assign_material", self.file, product=wall, material=material_set) + assert len(self.file.by_type("IfcMaterialConstituentSet")[0].MaterialConstituents) == 1 + ifcopenshell.api.run("material.remove_material", self.file, material=material) + assert len(self.file.by_type("IfcMaterial")) == 0 + assert len(self.file.by_type("IfcRelAssociatesMaterial")) == 1 + assert self.file.by_type("IfcMaterialConstituentSet")[0].MaterialConstituents is None + + def test_removing_material_in_list(self): + wall = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + material = ifcopenshell.api.run("material.add_material", self.file) + material_set = ifcopenshell.api.run( + "material.add_material_set", self.file, set_type="IfcMaterialList" + ) + ifcopenshell.api.run("material.add_list_item", self.file, material_list=material_set, material=material) + ifcopenshell.api.run("material.assign_material", self.file, product=wall, material=material_set) + assert len(self.file.by_type("IfcMaterialList")[0].Materials) == 1 + ifcopenshell.api.run("material.remove_material", self.file, material=material) + assert len(self.file.by_type("IfcMaterial")) == 0 + assert len(self.file.by_type("IfcRelAssociatesMaterial")) == 1 + assert len(self.file.by_type("IfcMaterialList")[0].Materials) == 0 From a43fae68b6dd02ca04483d0ab26d9330a1a2a732 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 19 Mar 2022 22:12:58 +1100 Subject: [PATCH 32/85] #2094. You can now add material sets independently of IFC objects in the material manager --- .../bim/module/material/__init__.py | 2 +- .../bim/module/material/operator.py | 46 ++++--------- .../blenderbim/bim/module/material/ui.py | 13 +++- src/blenderbim/blenderbim/core/material.py | 24 +++++-- src/blenderbim/blenderbim/core/tool.py | 3 + src/blenderbim/blenderbim/tool/material.py | 12 ++++ .../test/bim/feature/material.feature | 31 ++++++--- src/blenderbim/test/core/test_material.py | 68 +++++++++++++++++-- src/blenderbim/test/tool/test_material.py | 23 +++++++ 9 files changed, 170 insertions(+), 52 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/material/__init__.py b/src/blenderbim/blenderbim/bim/module/material/__init__.py index 0c42d9c04d..d836036937 100644 --- a/src/blenderbim/blenderbim/bim/module/material/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/material/__init__.py @@ -21,10 +21,10 @@ from . import ui, prop, operator classes = ( operator.AddConstituent, - operator.AddDefaultMaterial, operator.AddLayer, operator.AddListItem, operator.AddMaterial, + operator.AddMaterialSet, operator.AddProfile, operator.AssignMaterial, operator.AssignParameterizedProfile, diff --git a/src/blenderbim/blenderbim/bim/module/material/operator.py b/src/blenderbim/blenderbim/bim/module/material/operator.py index 4fa6685123..0bc6e0f969 100644 --- a/src/blenderbim/blenderbim/bim/module/material/operator.py +++ b/src/blenderbim/blenderbim/bim/module/material/operator.py @@ -79,45 +79,29 @@ class AssignParameterizedProfile(bpy.types.Operator): return {"FINISHED"} -class AddDefaultMaterial(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.add_default_material" - bl_label = "Add Default Material" - bl_options = {"REGISTER", "UNDO"} - - def _execute(self, context): - core.add_default_material(tool.Ifc, tool.Material) - Data.load(IfcStore.get_file()) - - -class AddMaterial(bpy.types.Operator): +class AddMaterial(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_material" bl_label = "Add Material" bl_options = {"REGISTER", "UNDO"} obj: bpy.props.StringProperty() - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - def _execute(self, context): - obj = bpy.data.materials.get(self.obj) if self.obj else context.active_object.active_material - self.file = IfcStore.get_file() - result = ifcopenshell.api.run("material.add_material", self.file, **{"name": obj.name}) - IfcStore.link_element(result, obj) - if obj.BIMMaterialProperties.ifc_style_id: - context = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW") - if context: - ifcopenshell.api.run( - "style.assign_material_style", - self.file, - **{ - "material": result, - "style": self.file.by_id(obj.BIMMaterialProperties.ifc_style_id), - "context": context, - }, - ) + obj = bpy.data.materials.get(self.obj) if self.obj else None + core.add_material(tool.Ifc, tool.Material, tool.Style, obj=obj) + Data.load(IfcStore.get_file()) + material_prop_purge() + + +class AddMaterialSet(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_material_set" + bl_label = "Add Material Set" + bl_options = {"REGISTER", "UNDO"} + set_type: bpy.props.StringProperty() + + def _execute(self, context): + core.add_material_set(tool.Ifc, tool.Material, set_type=self.set_type) Data.load(IfcStore.get_file()) material_prop_purge() - return {"FINISHED"} class RemoveMaterial(bpy.types.Operator): diff --git a/src/blenderbim/blenderbim/bim/module/material/ui.py b/src/blenderbim/blenderbim/bim/module/material/ui.py index 24f345a125..0bbc09c630 100644 --- a/src/blenderbim/blenderbim/bim/module/material/ui.py +++ b/src/blenderbim/blenderbim/bim/module/material/ui.py @@ -54,6 +54,14 @@ class BIM_PT_materials(Panel): row.operator("bim.load_materials", text="", icon="IMPORT") return + row = self.layout.row(align=True) + row.alignment = "RIGHT" + + if self.props.material_type == "IfcMaterial": + row.operator("bim.add_material", text="", icon="ADD") + else: + row.operator("bim.add_material_set", text="", icon="ADD").set_type = self.props.material_type + self.layout.template_list("BIM_UL_materials", "", self.props, "materials", self.props, "active_material_index") @@ -74,7 +82,8 @@ class BIM_PT_material(Panel): row.operator("bim.remove_material", icon="X", text="Remove IFC Material") row.operator("bim.unlink_material", icon="UNLINKED", text="") else: - row.operator("bim.add_material", icon="ADD", text="Create IFC Material") + op = row.operator("bim.add_material", icon="ADD", text="Create IFC Material") + op.obj = context.active_object.active_material.name class BIM_PT_object_material(Panel): @@ -113,7 +122,7 @@ class BIM_PT_object_material(Panel): if not Data.materials: row = self.layout.row(align=True) row.label(text="No Materials Available") - row.operator("bim.add_default_material", icon="ADD", text="") + row.operator("bim.add_material", icon="ADD", text="").obj = "" return if self.product_data: diff --git a/src/blenderbim/blenderbim/core/material.py b/src/blenderbim/blenderbim/core/material.py index ce2f6466dc..df9b35939c 100644 --- a/src/blenderbim/blenderbim/core/material.py +++ b/src/blenderbim/blenderbim/core/material.py @@ -21,10 +21,26 @@ def unlink_material(ifc, obj=None): ifc.unlink(obj=obj) -def add_default_material(ifc, material): - obj = material.add_default_material_object() - ifc.link(ifc.run("material.add_material", name="Default"), obj) - return obj +def add_material(ifc, material, style, obj=None): + if not obj: + obj = material.add_default_material_object() + ifc_material = ifc.run("material.add_material", name=material.get_name(obj)) + ifc.link(ifc_material, obj) + ifc_style = style.get_style(obj) + if ifc_style: + context = style.get_context(obj) + if context: + ifc.run("style.assign_material_style", material=ifc_material, style=ifc_style, context=context) + if material.is_editing_materials(): + material.import_material_definitions(material.get_active_material_type()) + return ifc_material + + +def add_material_set(ifc, material, set_type=None): + ifc_material = ifc.run("material.add_material_set", name="Unnamed", set_type=set_type) + if material.is_editing_materials(): + material.import_material_definitions(material.get_active_material_type()) + return ifc_material def load_materials(material, material_type): diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index f4a9450609..0e9f272a1d 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -278,7 +278,10 @@ class Material: def add_default_material_object(cls): pass def disable_editing_materials(cls): pass def enable_editing_materials(cls): pass + def get_active_material_type(cls): pass + def get_name(cls, obj): pass def import_material_definitions(cls, material_type): pass + def is_editing_materials(cls): pass @interface diff --git a/src/blenderbim/blenderbim/tool/material.py b/src/blenderbim/blenderbim/tool/material.py index 0c965347fe..304d9770e6 100644 --- a/src/blenderbim/blenderbim/tool/material.py +++ b/src/blenderbim/blenderbim/tool/material.py @@ -36,6 +36,14 @@ class Material(blenderbim.core.tool.Material): def enable_editing_materials(cls): bpy.context.scene.BIMMaterialProperties.is_editing = True + @classmethod + def get_active_material_type(cls): + return bpy.context.scene.BIMMaterialProperties.material_type + + @classmethod + def get_name(cls, obj): + return obj.name + @classmethod def import_material_definitions(cls, material_type): props = bpy.context.scene.BIMMaterialProperties @@ -49,3 +57,7 @@ class Material(blenderbim.core.tool.Material): new.name = "Unnamed" else: new.name = material.Name or "Unnamed" + + @classmethod + def is_editing_materials(cls): + return bpy.context.scene.BIMMaterialProperties.is_editing diff --git a/src/blenderbim/test/bim/feature/material.feature b/src/blenderbim/test/bim/feature/material.feature index 4dafefaec2..db52e2b897 100644 --- a/src/blenderbim/test/bim/feature/material.feature +++ b/src/blenderbim/test/bim/feature/material.feature @@ -3,20 +3,33 @@ Feature: Material Scenario: Load materials Given an empty IFC project - And I press "bim.add_default_material" + And I press "bim.add_material(obj='')" When I press "bim.load_materials" Then nothing happens Scenario: Disable editing materials Given an empty IFC project - And I press "bim.add_default_material" + And I press "bim.add_material(obj='')" And I press "bim.load_materials" When I press "bim.disable_editing_materials" Then nothing happens +Scenario: Load materials - then add material + Given an empty IFC project + And I press "bim.load_materials" + When I press "bim.add_material(obj='')" + Then the material "Default" exists + +Scenario: Load materials - then add material set + Given an empty IFC project + And I set "scene.BIMMaterialProperties.material_type" to "IfcMaterialLayerSet" + And I press "bim.load_materials" + When I press "bim.add_material_set(set_type='IfcMaterialLayerSet')" + Then nothing happens + Scenario: Add default material Given an empty IFC project - When I press "bim.add_default_material" + When I press "bim.add_material(obj='')" Then the material "Default" exists Scenario: Add material @@ -24,7 +37,7 @@ Scenario: Add material And I add a cube And the object "Cube" is selected And I add a material - When I press "bim.add_material" + When I press "bim.add_material(obj='Material')" Then the material "Material" is an IFC material Scenario: Remove material @@ -32,7 +45,7 @@ Scenario: Remove material And I add a cube And the object "Cube" is selected And I add a material - And I press "bim.add_material" + And I press "bim.add_material(obj='Material')" When I press "bim.remove_material" Then the material "Material" is not an IFC material @@ -41,7 +54,7 @@ Scenario: Unlink material And I add a cube And the object "Cube" is selected And I add a material - And I press "bim.add_material" + And I press "bim.add_material(obj='Material')" When I press "bim.unlink_material" Then the material "Material" is not an IFC material @@ -51,7 +64,7 @@ Scenario: Assign material - Assign a material And the object "Cube" is selected And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" - And I press "bim.add_default_material" + And I press "bim.add_material(obj='')" When I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterial" And I press "bim.assign_material" Then the object "IfcWall/Cube" has the material "Default" @@ -67,7 +80,7 @@ Scenario: Assign material - Assign a material layer set And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType" And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType" And I press "bim.assign_class" - And I press "bim.add_default_material" + And I press "bim.add_material(obj='')" When I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet" And I press "bim.assign_material" Then nothing happens @@ -83,7 +96,7 @@ Scenario: Assign material - Assign a material profile set And I set "scene.BIMRootProperties.ifc_product" to "IfcElementType" And I set "scene.BIMRootProperties.ifc_class" to "IfcWallType" And I press "bim.assign_class" - And I press "bim.add_default_material" + And I press "bim.add_material(obj='')" When I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialProfileSet" And I press "bim.assign_material" Then nothing happens diff --git a/src/blenderbim/test/core/test_material.py b/src/blenderbim/test/core/test_material.py index 86404c1aa6..5bdabdd9cd 100644 --- a/src/blenderbim/test/core/test_material.py +++ b/src/blenderbim/test/core/test_material.py @@ -17,7 +17,7 @@ # along with BlenderBIM Add-on. If not, see . import blenderbim.core.material as subject -from test.core.bootstrap import ifc, material +from test.core.bootstrap import ifc, material, style class TestUnlinkMaterial: @@ -26,12 +26,70 @@ class TestUnlinkMaterial: subject.unlink_material(ifc, obj="obj") -class TestAddDefaultMaterial: - def test_run(self, ifc, material): +class TestAddMaterial: + def test_add_a_default_material(self, ifc, material, style): material.add_default_material_object().should_be_called().will_return("obj") - ifc.run("material.add_material", name="Default").should_be_called().will_return("material") + material.get_name("obj").should_be_called().will_return("name") + ifc.run("material.add_material", name="name").should_be_called().will_return("material") ifc.link("material", "obj").should_be_called() - assert subject.add_default_material(ifc, material) == "obj" + style.get_style("obj").should_be_called().will_return(None) + material.is_editing_materials().should_be_called().will_return(False) + assert subject.add_material(ifc, material, style) == "material" + + def test_add_a_material_to_a_blender_material_object(self, ifc, material, style): + material.get_name("obj").should_be_called().will_return("name") + ifc.run("material.add_material", name="name").should_be_called().will_return("material") + ifc.link("material", "obj").should_be_called() + style.get_style("obj").should_be_called().will_return(None) + material.is_editing_materials().should_be_called().will_return(False) + assert subject.add_material(ifc, material, style, obj="obj") == "material" + + def test_reloading_imported_materials_if_you_are_editing_scene_materials(self, ifc, material, style): + material.get_name("obj").should_be_called().will_return("name") + ifc.run("material.add_material", name="name").should_be_called().will_return("material") + ifc.link("material", "obj").should_be_called() + style.get_style("obj").should_be_called().will_return(None) + material.is_editing_materials().should_be_called().will_return(True) + material.get_active_material_type().should_be_called().will_return("material_type") + material.import_material_definitions("material_type").should_be_called() + assert subject.add_material(ifc, material, style, obj="obj") == "material" + + def test_add_a_style_to_the_material_if_the_object_also_has_an_attached_style(self, ifc, material, style): + material.get_name("obj").should_be_called().will_return("name") + ifc.run("material.add_material", name="name").should_be_called().will_return("material") + ifc.link("material", "obj").should_be_called() + style.get_style("obj").should_be_called().will_return("style") + style.get_context("obj").should_be_called().will_return("context") + ifc.run("style.assign_material_style", material="material", style="style", context="context").should_be_called() + material.is_editing_materials().should_be_called().will_return(False) + assert subject.add_material(ifc, material, style, obj="obj") == "material" + + def test_not_adding_a_style_if_there_is_no_style_context_available(self, ifc, material, style): + material.get_name("obj").should_be_called().will_return("name") + ifc.run("material.add_material", name="name").should_be_called().will_return("material") + ifc.link("material", "obj").should_be_called() + style.get_style("obj").should_be_called().will_return("style") + style.get_context("obj").should_be_called().will_return(None) + material.is_editing_materials().should_be_called().will_return(False) + assert subject.add_material(ifc, material, style, obj="obj") == "material" + + +class TestAddMaterialSet: + def test_adding_a_material_set(self, ifc, material): + ifc.run("material.add_material_set", name="Unnamed", set_type="set_type").should_be_called().will_return( + "material" + ) + material.is_editing_materials().should_be_called().will_return(False) + assert subject.add_material_set(ifc, material, set_type="set_type") == "material" + + def test_adding_a_material_set_and_reloading_imported_materials(self, ifc, material): + ifc.run("material.add_material_set", name="Unnamed", set_type="set_type").should_be_called().will_return( + "material" + ) + material.is_editing_materials().should_be_called().will_return(True) + material.get_active_material_type().should_be_called().will_return("material_type") + material.import_material_definitions("material_type").should_be_called() + assert subject.add_material_set(ifc, material, set_type="set_type") == "material" class TestLoadMaterials: diff --git a/src/blenderbim/test/tool/test_material.py b/src/blenderbim/test/tool/test_material.py index bd739a864e..bf5ebea230 100644 --- a/src/blenderbim/test/tool/test_material.py +++ b/src/blenderbim/test/tool/test_material.py @@ -50,6 +50,21 @@ class TestEnableEditingMaterials(NewFile): assert bpy.context.scene.BIMMaterialProperties.is_editing is True +class TestGetActiveMaterialType(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + bpy.context.scene.BIMMaterialProperties.material_type = "IfcMaterial" + assert subject.get_active_material_type() == "IfcMaterial" + bpy.context.scene.BIMMaterialProperties.material_type = "IfcMaterialLayerSet" + assert subject.get_active_material_type() == "IfcMaterialLayerSet" + + +class TestGetName(NewFile): + def test_run(self): + assert subject.get_name(bpy.data.materials.new("Material")) == "Material" + + class TestImportMaterialDefinitions(NewFile): def test_import_materials(self): ifc = ifcopenshell.file() @@ -95,3 +110,11 @@ class TestImportMaterialDefinitions(NewFile): props = bpy.context.scene.BIMMaterialProperties assert props.materials[0].ifc_definition_id == material.id() assert props.materials[0].name == "Unnamed" + + +class TestIsEditingMaterials(NewFile): + def test_run(self): + bpy.context.scene.BIMMaterialProperties.is_editing = False + subject.is_editing_materials() is False + bpy.context.scene.BIMMaterialProperties.is_editing = True + subject.is_editing_materials() is True From 1b5c20e14ff906997006c8dd20f3463025a99839 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sat, 19 Mar 2022 14:45:19 +0100 Subject: [PATCH 33/85] #650 fclose() --- src/ifcparse/IfcParse.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ifcparse/IfcParse.cpp b/src/ifcparse/IfcParse.cpp index aa655c1945..ee6bccfee6 100644 --- a/src/ifcparse/IfcParse.cpp +++ b/src/ifcparse/IfcParse.cpp @@ -212,6 +212,9 @@ void IfcSpfStream::Close() { } #endif delete[] buffer; + if (stream) { + fclose(stream); + } } // From a1ba61636d18f8d604ba0512f4073b2c61a9ab81 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sat, 19 Mar 2022 19:32:42 +0000 Subject: [PATCH 34/85] HDF5 debug postfix --- cmake/CMakeLists.txt | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index 7b4600c77a..c904c85509 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -62,6 +62,10 @@ endif() OPTION(USERSPACE_PYTHON_PREFIX "Installs IfcPython for the current user only instead of system-wide." OFF) OPTION(ADD_COMMIT_SHA "Add commit sha and branch in version number, warning results in many rebuilds, requires git" OFF) +IF(NOT CMAKE_BUILD_TYPE) + SET(CMAKE_BUILD_TYPE "Release") +ENDIF() + # TODO QtViewer is deprecated ATM as it uses the 0.4 API # OPTION(BUILD_QTVIEWER "Build IfcOpenShell Qt GUI Viewer (requires Qt 4 framework)." OFF) include(GNUInstallDirs) @@ -444,13 +448,17 @@ if(HDF5_SUPPORT) else() set(lib_ext a) endif() + + if ("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") + set(debug_postfix "_debug") + endif() SET(HDF5_LIBRARIES - "${HDF5_LIBRARY_DIR}/libhdf5_cpp.${lib_ext}" - "${HDF5_LIBRARY_DIR}/libhdf5.${lib_ext}" - "${HDF5_LIBRARY_DIR}/libz${zlib_post}.${lib_ext}" - "${HDF5_LIBRARY_DIR}/libsz.${lib_ext}" - "${HDF5_LIBRARY_DIR}/libaec.${lib_ext}" + "${HDF5_LIBRARY_DIR}/libhdf5_cpp${debug_postfix}.${lib_ext}" + "${HDF5_LIBRARY_DIR}/libhdf5${debug_postfix}.${lib_ext}" + "${HDF5_LIBRARY_DIR}/libz${zlib_post}${debug_postfix}.${lib_ext}" + "${HDF5_LIBRARY_DIR}/libsz${debug_postfix}.${lib_ext}" + "${HDF5_LIBRARY_DIR}/libaec${debug_postfix}.${lib_ext}" ) endif() @@ -470,10 +478,6 @@ if(HDF5_SUPPORT) set(SWIG_DEFINES ${SWIG_DEFINES} -DWITH_HDF5) endif() -IF(NOT CMAKE_BUILD_TYPE) - SET(CMAKE_BUILD_TYPE "Release") -ENDIF() - if(ENABLE_BUILD_OPTIMIZATIONS) if(MSVC) # NOTE: RelWithDebInfo and Release use O2 (= /Ox /Gl /Gy/ = Og /Oi /Ot /Oy /Ob2 /Gs /GF /Gy) by default, From 2985bba157da40030b4b6e2159481ae08cbd603f Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sat, 19 Mar 2022 20:18:12 +0000 Subject: [PATCH 35/85] #650 Unsure about 1b5c20e14ff906997006c8dd20f3463025a99839 --- src/ifcparse/IfcParse.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ifcparse/IfcParse.cpp b/src/ifcparse/IfcParse.cpp index ee6bccfee6..51d344033b 100644 --- a/src/ifcparse/IfcParse.cpp +++ b/src/ifcparse/IfcParse.cpp @@ -168,6 +168,7 @@ IfcSpfStream::IfcSpfStream(const std::string& fn) eof = len == 0; ptr = 0; fclose(stream); + stream = nullptr; #ifdef USE_MMAP } #endif From e909f254329e18b7b62853eb3d28f0daa6a6d969 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 21 Mar 2022 10:22:25 +1100 Subject: [PATCH 36/85] Removing a property set now also removes properties --- .../ifcopenshell/api/pset/remove_pset.py | 7 ++- .../test/api/pset/test_remove_pset.py | 59 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/ifcopenshell-python/test/api/pset/test_remove_pset.py diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py index 505593d81a..e2d663cf8f 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py @@ -37,7 +37,12 @@ class Usecase: inverse.RelatedObjects = related_objects should_remove_pset = False if should_remove_pset: + if self.settings["pset"].is_a("IfcPropertySet"): + properties = self.settings["pset"].HasProperties or [] + elif self.settings["pset"].is_a("IfcQuantitySet"): + properties = self.settings["pset"].Quantities or [] + for prop in properties: + self.file.remove(prop) self.file.remove(self.settings["pset"]) for element in to_purge: self.file.remove(element) - # TODO: implement deep purging diff --git a/src/ifcopenshell-python/test/api/pset/test_remove_pset.py b/src/ifcopenshell-python/test/api/pset/test_remove_pset.py new file mode 100644 index 0000000000..4f5291a3ef --- /dev/null +++ b/src/ifcopenshell-python/test/api/pset/test_remove_pset.py @@ -0,0 +1,59 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import test.bootstrap +import ifcopenshell.api + + +class TestRemovePset(test.bootstrap.IFC4): + def test_removing_pset(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 1 + ifcopenshell.api.run("pset.remove_pset", self.file, product=element, pset=pset) + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 0 + assert len(self.file.by_type("IfcPropertySet")) == 0 + + def test_only_unassigning_if_pset_is_used_by_other_elements(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + element2 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") + rel = self.file.by_type("IfcRelDefinesByProperties")[0] + rel.RelatedObjects = [element, element2] + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 1 + ifcopenshell.api.run("pset.remove_pset", self.file, product=element, pset=pset) + assert ifcopenshell.util.element.get_psets(element) == {} + assert "Foo_Bar" in ifcopenshell.util.element.get_psets(element2) + + def test_removing_a_pset_with_properties(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": "Bar"}) + ifcopenshell.api.run("pset.remove_pset", self.file, product=element, pset=pset) + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 0 + assert len(self.file.by_type("IfcPropertySet")) == 0 + assert len(self.file.by_type("IfcPropertySingleValue")) == 0 + + def test_removing_a_qto_with_quantities(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + qto = ifcopenshell.api.run("pset.add_qto", self.file, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_qto", self.file, qto=qto, properties={"Foo": 42}) + ifcopenshell.api.run("pset.remove_pset", self.file, product=element, pset=qto) + assert len(self.file.by_type("IfcRelDefinesByProperties")) == 0 + assert len(self.file.by_type("IfcQuantitySet")) == 0 + assert len(self.file.by_type("IfcPhysicalSimpleQuantity")) == 0 From 0b4cfea3a966bba84b84e52a0e0da24487e00ffc Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 21 Mar 2022 10:50:19 +1100 Subject: [PATCH 37/85] Removing a material now also removes material properties --- .../api/material/remove_material.py | 4 +++ .../test/api/material/test_remove_material.py | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/remove_material.py b/src/ifcopenshell-python/ifcopenshell/api/material/remove_material.py index 42bc5c5fd9..bcb71b1da5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/material/remove_material.py +++ b/src/ifcopenshell-python/ifcopenshell/api/material/remove_material.py @@ -40,6 +40,10 @@ class Usecase: self.file.remove(inverse) elif inverse.is_a("IfcRelAssociatesMaterial"): self.file.remove(inverse) + elif inverse.is_a("IfcMaterialProperties"): + for prop in inverse.Properties or []: + self.file.remove(prop) + self.file.remove(inverse) elif inverse.is_a("IfcMaterialDefinitionRepresentation"): for representation in inverse.Representations: for item in representation.Items: diff --git a/src/ifcopenshell-python/test/api/material/test_remove_material.py b/src/ifcopenshell-python/test/api/material/test_remove_material.py index 9d24f0a04d..80e0abb1f3 100644 --- a/src/ifcopenshell-python/test/api/material/test_remove_material.py +++ b/src/ifcopenshell-python/test/api/material/test_remove_material.py @@ -89,3 +89,29 @@ class TestRemoveMaterial(test.bootstrap.IFC4): assert len(self.file.by_type("IfcMaterial")) == 0 assert len(self.file.by_type("IfcRelAssociatesMaterial")) == 1 assert len(self.file.by_type("IfcMaterialList")[0].Materials) == 0 + + def test_removing_a_material_with_a_style_definition(self): + ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcProject") + context = ifcopenshell.api.run("context.add_context", self.file, context_type="Model") + material = ifcopenshell.api.run("material.add_material", self.file) + style = ifcopenshell.api.run("style.add_style", self.file) + ifcopenshell.api.run("style.assign_material_style", self.file, material=material, style=style, context=context) + assert len(self.file.by_type("IfcMaterialDefinitionRepresentation")) == 1 + assert len(self.file.by_type("IfcStyledRepresentation")) == 1 + assert len(self.file.by_type("IfcStyledItem")) == 1 + assert len(self.file.by_type("IfcSurfaceStyle")) == 1 + ifcopenshell.api.run("material.remove_material", self.file, material=material) + assert len(self.file.by_type("IfcMaterial")) == 0 + assert len(self.file.by_type("IfcMaterialDefinitionRepresentation")) == 0 + assert len(self.file.by_type("IfcStyledRepresentation")) == 0 + assert len(self.file.by_type("IfcStyledItem")) == 0 + assert len(self.file.by_type("IfcSurfaceStyle")) == 1 + + def test_removing_a_material_with_properties(self): + material = ifcopenshell.api.run("material.add_material", self.file) + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=material, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": "Bar"}) + assert material.HasProperties + ifcopenshell.api.run("material.remove_material", self.file, material=material) + assert len(self.file.by_type("IfcMaterialProperties")) == 0 + assert len(self.file.by_type("IfcPropertySingleValue")) == 0 From ea530858bd20bf7ddf9c78d6429bf7fa150efe1d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 21 Mar 2022 12:03:15 +1100 Subject: [PATCH 38/85] You can now remove material sets independent of elements --- .../api/material/remove_material_set.py | 48 +++++++++++++++++ .../api/material/test_remove_material_set.py | 53 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/ifcopenshell-python/ifcopenshell/api/material/remove_material_set.py create mode 100644 src/ifcopenshell-python/test/api/material/test_remove_material_set.py diff --git a/src/ifcopenshell-python/ifcopenshell/api/material/remove_material_set.py b/src/ifcopenshell-python/ifcopenshell/api/material/remove_material_set.py new file mode 100644 index 0000000000..f002b3a9fd --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/material/remove_material_set.py @@ -0,0 +1,48 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell + + +class Usecase: + def __init__(self, file, **settings): + self.file = file + self.settings = {"material": None} + for key, value in settings.items(): + self.settings[key] = value + + def execute(self): + inverse_elements = self.file.get_inverse(self.settings["material"]) + if self.settings["material"].is_a("IfcMaterialLayerSet"): + set_items = self.settings["material"].MaterialLayers or [] + elif self.settings["material"].is_a("IfcMaterialProfileSet"): + set_items = self.settings["material"].MaterialProfiles or [] + elif self.settings["material"].is_a("IfcMaterialConstituentSet"): + set_items = self.settings["material"].MaterialConstituents or [] + elif self.settings["material"].is_a("IfcMaterialList"): + set_items = [] + for set_item in set_items: + self.file.remove(set_item) + self.file.remove(self.settings["material"]) + for inverse in inverse_elements: + if inverse.is_a("IfcRelAssociatesMaterial"): + self.file.remove(inverse) + elif inverse.is_a("IfcMaterialProperties"): + for prop in inverse.Properties or []: + self.file.remove(prop) + self.file.remove(inverse) diff --git a/src/ifcopenshell-python/test/api/material/test_remove_material_set.py b/src/ifcopenshell-python/test/api/material/test_remove_material_set.py new file mode 100644 index 0000000000..fe78ce351c --- /dev/null +++ b/src/ifcopenshell-python/test/api/material/test_remove_material_set.py @@ -0,0 +1,53 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import test.bootstrap +import ifcopenshell.api + + +class TestRemoveMaterialSet(test.bootstrap.IFC4): + def test_removing_material_set(self): + material = ifcopenshell.api.run("material.add_material_set", self.file, set_type="IfcMaterialLayerSet") + ifcopenshell.api.run("material.remove_material_set", self.file, material=material) + assert len(self.file.by_type("IfcMaterialLayerSet")) == 0 + + def test_removing_material_set_with_associations(self): + wall = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + material = ifcopenshell.api.run("material.add_material_set", self.file, set_type="IfcMaterialLayerSet") + ifcopenshell.api.run("material.assign_material", self.file, product=wall, material=material) + ifcopenshell.api.run("material.remove_material_set", self.file, material=material) + assert len(self.file.by_type("IfcMaterialLayerSet")) == 0 + assert len(self.file.by_type("IfcRelAssociatesMaterial")) == 0 + + def test_removing_a_material_set_with_set_items_but_preserving_materials(self): + material = ifcopenshell.api.run("material.add_material", self.file) + material_set = ifcopenshell.api.run("material.add_material_set", self.file, set_type="IfcMaterialLayerSet") + ifcopenshell.api.run("material.add_layer", self.file, layer_set=material_set, material=material) + ifcopenshell.api.run("material.remove_material_set", self.file, material=material_set) + assert len(self.file.by_type("IfcMaterialLayerSet")) == 0 + assert len(self.file.by_type("IfcMaterialLayer")) == 0 + assert len(self.file.by_type("IfcMaterial")) == 1 + + def test_removing_a_material_set_with_properties(self): + material = ifcopenshell.api.run("material.add_material_set", self.file, set_type="IfcMaterialLayerSet") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=material, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": "Bar"}) + assert material.HasProperties + ifcopenshell.api.run("material.remove_material_set", self.file, material=material) + assert len(self.file.by_type("IfcMaterialProperties")) == 0 + assert len(self.file.by_type("IfcPropertySingleValue")) == 0 From c478e5bf1b30f1758c38dbfc8d999f8ad0250030 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 21 Mar 2022 12:04:51 +1100 Subject: [PATCH 39/85] #2094. Deleting materials now also deletes Blender material if not used for a style --- .../bim/module/material/__init__.py | 1 + .../bim/module/material/operator.py | 28 ++++++------ .../blenderbim/bim/module/material/ui.py | 11 ++++- src/blenderbim/blenderbim/core/material.py | 16 +++++++ src/blenderbim/blenderbim/core/tool.py | 1 + src/blenderbim/blenderbim/tool/material.py | 4 ++ .../test/bim/feature/material.feature | 14 +++++- src/blenderbim/test/bim/test_feature.py | 5 +++ src/blenderbim/test/core/test_material.py | 44 +++++++++++++++++++ src/blenderbim/test/tool/test_material.py | 8 ++++ 10 files changed, 114 insertions(+), 18 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/material/__init__.py b/src/blenderbim/blenderbim/bim/module/material/__init__.py index d836036937..47bd7b6d0f 100644 --- a/src/blenderbim/blenderbim/bim/module/material/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/material/__init__.py @@ -41,6 +41,7 @@ classes = ( operator.RemoveLayer, operator.RemoveListItem, operator.RemoveMaterial, + operator.RemoveMaterialSet, operator.RemoveProfile, operator.ReorderMaterialSetItem, operator.UnassignMaterial, diff --git a/src/blenderbim/blenderbim/bim/module/material/operator.py b/src/blenderbim/blenderbim/bim/module/material/operator.py index 0bc6e0f969..e2d0542ae8 100644 --- a/src/blenderbim/blenderbim/bim/module/material/operator.py +++ b/src/blenderbim/blenderbim/bim/module/material/operator.py @@ -104,26 +104,26 @@ class AddMaterialSet(bpy.types.Operator, tool.Ifc.Operator): material_prop_purge() -class RemoveMaterial(bpy.types.Operator): +class RemoveMaterial(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_material" bl_label = "Remove Material" bl_options = {"REGISTER", "UNDO"} - obj: bpy.props.StringProperty() - - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) + material: bpy.props.IntProperty() def _execute(self, context): - obj = bpy.data.materials.get(self.obj) if self.obj else context.active_object.active_material - self.file = IfcStore.get_file() - result = ifcopenshell.api.run( - "material.remove_material", - self.file, - **{"material": self.file.by_id(obj.BIMObjectProperties.ifc_definition_id)}, - ) - obj.BIMObjectProperties.ifc_definition_id = 0 + core.remove_material(tool.Ifc, tool.Material, tool.Style, material=tool.Ifc.get().by_id(self.material)) + Data.load(IfcStore.get_file()) + + +class RemoveMaterialSet(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.remove_material_set" + bl_label = "Remove Material Set" + bl_options = {"REGISTER", "UNDO"} + material: bpy.props.IntProperty() + + def _execute(self, context): + core.remove_material_set(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material)) Data.load(IfcStore.get_file()) - return {"FINISHED"} class UnlinkMaterial(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/blenderbim/blenderbim/bim/module/material/ui.py b/src/blenderbim/blenderbim/bim/module/material/ui.py index 0bbc09c630..7ea70c5813 100644 --- a/src/blenderbim/blenderbim/bim/module/material/ui.py +++ b/src/blenderbim/blenderbim/bim/module/material/ui.py @@ -59,8 +59,14 @@ class BIM_PT_materials(Panel): if self.props.material_type == "IfcMaterial": row.operator("bim.add_material", text="", icon="ADD") + if self.props.materials and self.props.active_material_index < len(self.props.materials): + material = self.props.materials[self.props.active_material_index] + row.operator("bim.remove_material", text="", icon="X").material = material.ifc_definition_id else: row.operator("bim.add_material_set", text="", icon="ADD").set_type = self.props.material_type + if self.props.materials and self.props.active_material_index < len(self.props.materials): + material = self.props.materials[self.props.active_material_index] + row.operator("bim.remove_material_set", text="", icon="X").material = material.ifc_definition_id self.layout.template_list("BIM_UL_materials", "", self.props, "materials", self.props, "active_material_index") @@ -78,8 +84,9 @@ class BIM_PT_material(Panel): def draw(self, context): row = self.layout.row(align=True) - if bool(context.active_object.active_material.BIMObjectProperties.ifc_definition_id): - row.operator("bim.remove_material", icon="X", text="Remove IFC Material") + material_id = context.active_object.active_material.BIMObjectProperties.ifc_definition_id + if bool(material_id): + row.operator("bim.remove_material", icon="X", text="Remove IFC Material").material = material_id row.operator("bim.unlink_material", icon="UNLINKED", text="") else: op = row.operator("bim.add_material", icon="ADD", text="Create IFC Material") diff --git a/src/blenderbim/blenderbim/core/material.py b/src/blenderbim/blenderbim/core/material.py index df9b35939c..15dd2442e8 100644 --- a/src/blenderbim/blenderbim/core/material.py +++ b/src/blenderbim/blenderbim/core/material.py @@ -43,6 +43,22 @@ def add_material_set(ifc, material, set_type=None): return ifc_material +def remove_material(ifc, material_tool, style, material=None): + obj = ifc.get_object(material) + ifc.unlink(element=material) + ifc.run("material.remove_material", material=material) + if obj and not style.get_style(obj): + material_tool.delete_object(obj) + if material_tool.is_editing_materials(): + material_tool.import_material_definitions(material_tool.get_active_material_type()) + + +def remove_material_set(ifc, material_tool, material=None): + ifc.run("material.remove_material_set", material=material) + if material_tool.is_editing_materials(): + material_tool.import_material_definitions(material_tool.get_active_material_type()) + + def load_materials(material, material_type): material.import_material_definitions(material_type) material.enable_editing_materials() diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index 0e9f272a1d..21f8796d6c 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -276,6 +276,7 @@ class Library: @interface class Material: def add_default_material_object(cls): pass + def delete_object(cls, obj): pass def disable_editing_materials(cls): pass def enable_editing_materials(cls): pass def get_active_material_type(cls): pass diff --git a/src/blenderbim/blenderbim/tool/material.py b/src/blenderbim/blenderbim/tool/material.py index 304d9770e6..0e27d54803 100644 --- a/src/blenderbim/blenderbim/tool/material.py +++ b/src/blenderbim/blenderbim/tool/material.py @@ -28,6 +28,10 @@ class Material(blenderbim.core.tool.Material): def add_default_material_object(cls): return bpy.data.materials.new("Default") + @classmethod + def delete_object(cls, obj): + bpy.data.materials.remove(obj) + @classmethod def disable_editing_materials(cls): bpy.context.scene.BIMMaterialProperties.is_editing = False diff --git a/src/blenderbim/test/bim/feature/material.feature b/src/blenderbim/test/bim/feature/material.feature index db52e2b897..cbea52096b 100644 --- a/src/blenderbim/test/bim/feature/material.feature +++ b/src/blenderbim/test/bim/feature/material.feature @@ -46,8 +46,18 @@ Scenario: Remove material And the object "Cube" is selected And I add a material And I press "bim.add_material(obj='Material')" - When I press "bim.remove_material" - Then the material "Material" is not an IFC material + And the variable "material" is "{ifc}.by_type('IfcMaterial')[0].id()" + When I press "bim.remove_material(material={material})" + Then the material "Material" does not exist + +Scenario: Remove material set + Given an empty IFC project + And I set "scene.BIMMaterialProperties.material_type" to "IfcMaterialLayerSet" + And I press "bim.load_materials" + And I press "bim.add_material_set(set_type='IfcMaterialLayerSet')" + And the variable "material" is "{ifc}.by_type('IfcMaterialLayerSet')[0].id()" + When I press "bim.remove_material_set(material={material})" + Then nothing happens Scenario: Unlink material Given an empty IFC project diff --git a/src/blenderbim/test/bim/test_feature.py b/src/blenderbim/test/bim/test_feature.py index c28da88105..4cd6dbcd5a 100644 --- a/src/blenderbim/test/bim/test_feature.py +++ b/src/blenderbim/test/bim/test_feature.py @@ -297,6 +297,11 @@ def the_material_name_exists(name) -> bpy.types.Material: return obj +@then(parsers.parse('the material "{name}" does not exist')) +def the_material_name_does_not_exist(name): + assert bpy.data.materials.get(name) is None, "Material exists" + + @then("an IFC file does not exist") def an_ifc_file_does_not_exist(): ifc = IfcStore.get_file() diff --git a/src/blenderbim/test/core/test_material.py b/src/blenderbim/test/core/test_material.py index 5bdabdd9cd..accf5b50b7 100644 --- a/src/blenderbim/test/core/test_material.py +++ b/src/blenderbim/test/core/test_material.py @@ -92,6 +92,50 @@ class TestAddMaterialSet: assert subject.add_material_set(ifc, material, set_type="set_type") == "material" +class TestRemoveMaterial: + def test_removing_a_material(self, ifc, material, style): + ifc.get_object("material").should_be_called().will_return(None) + ifc.unlink(element="material").should_be_called() + ifc.run("material.remove_material", material="material").should_be_called() + material.is_editing_materials().should_be_called().will_return(False) + subject.remove_material(ifc, material, style, material="material") + + def test_removing_a_material_and_reloading_imported_materials(self, ifc, material, style): + ifc.get_object("material").should_be_called().will_return(None) + ifc.unlink(element="material").should_be_called() + ifc.run("material.remove_material", material="material").should_be_called() + material.is_editing_materials().should_be_called().will_return(True) + material.get_active_material_type().should_be_called().will_return("material_type") + material.import_material_definitions("material_type").should_be_called() + subject.remove_material(ifc, material, style, material="material") + + def test_removing_a_material_object_if_it_has_no_style(self, ifc, material, style): + ifc.get_object("material").should_be_called().will_return("obj") + ifc.unlink(element="material").should_be_called() + ifc.run("material.remove_material", material="material").should_be_called() + style.get_style("obj").should_be_called().will_return(None) + material.delete_object("obj").should_be_called() + material.is_editing_materials().should_be_called().will_return(False) + subject.remove_material(ifc, material, style, material="material") + + def test_preserving_a_material_object_if_it_is_still_used_as_a_style(self, ifc, material, style): + ifc.get_object("material").should_be_called().will_return("obj") + ifc.unlink(element="material").should_be_called() + ifc.run("material.remove_material", material="material").should_be_called() + style.get_style("obj").should_be_called().will_return("style") + material.is_editing_materials().should_be_called().will_return(False) + subject.remove_material(ifc, material, style, material="material") + + +class TestRemoveMaterialSet: + def test_run(self, ifc, material): + ifc.run("material.remove_material_set", material="material").should_be_called() + material.is_editing_materials().should_be_called().will_return(True) + material.get_active_material_type().should_be_called().will_return("material_type") + material.import_material_definitions("material_type").should_be_called() + subject.remove_material_set(ifc, material, material="material") + + class TestLoadMaterials: def test_run(self, material): material.import_material_definitions("material_type").should_be_called() diff --git a/src/blenderbim/test/tool/test_material.py b/src/blenderbim/test/tool/test_material.py index bf5ebea230..093aabec41 100644 --- a/src/blenderbim/test/tool/test_material.py +++ b/src/blenderbim/test/tool/test_material.py @@ -36,6 +36,14 @@ class TestAddDefaultMaterialObject(NewFile): assert material.name == "Default" +class TestDeleteObject(NewFile): + def test_run(self): + material = subject.add_default_material_object() + assert bpy.data.materials.get("Default") + subject.delete_object(material) + assert not bpy.data.materials.get("Default") + + class TestDisableEditingMaterials(NewFile): def test_run(self): bpy.context.scene.BIMMaterialProperties.is_editing = True From 44afa0df5d05a6f15a9ae6906efc199cf703b21f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 21 Mar 2022 17:28:16 +1100 Subject: [PATCH 40/85] Selector now uses classmethods to make it easier for scriptwriters to use --- .../ifcopenshell/util/selector.py | 62 +++++++++++-------- .../test/util/test_selector.py | 47 ++++++++++++++ 2 files changed, 83 insertions(+), 26 deletions(-) create mode 100644 src/ifcopenshell-python/test/util/test_selector.py diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index 326a36de56..b423cf4fbe 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -23,8 +23,9 @@ import lark class Selector: - def parse(self, ifc_file, query): - self.file = ifc_file + @classmethod + def parse(cls, ifc_file, query): + cls.file = ifc_file l = lark.Lark( """start: query (lfunction query)* @@ -82,13 +83,14 @@ class Selector: ) start = l.parse(query) - return self.get_group(start) + return cls.get_group(start) - def get_group(self, group): + @classmethod + def get_group(cls, group): lfunction = None for child in group.children: if child.data == "query": - new_results = self.get_query(child) + new_results = cls.get_query(child) if not lfunction: results = new_results elif lfunction == "or": @@ -100,14 +102,16 @@ class Selector: lfunction = child.children[0].data return results - def get_query(self, query): + @classmethod + def get_query(cls, query): for child in query.children: if child.data == "selector": - return self.get_selector(child) + return cls.get_selector(child) elif child.data == "group": - return self.get_group(child) + return cls.get_group(child) - def get_selector(self, selector): + @classmethod + def get_selector(cls, selector): if len(selector.children) == 1: inverse_relationship = None class_or_guid_selector = selector.children[0] @@ -116,15 +120,16 @@ class Selector: class_or_guid_selector = selector.children[1] if class_or_guid_selector.data == "class_selector": - results = self.get_class_selector(class_or_guid_selector) + results = cls.get_class_selector(class_or_guid_selector) elif class_or_guid_selector.data == "guid_selector": - results = self.get_guid_selector(class_or_guid_selector) + results = cls.get_guid_selector(class_or_guid_selector) if not inverse_relationship: return results - return self.parse_inverse_relationship(results, inverse_relationship.children[0].data) + return cls.parse_inverse_relationship(results, inverse_relationship.children[0].data) - def parse_inverse_relationship(self, elements, inverse_relationship): + @classmethod + def parse_inverse_relationship(cls, elements, inverse_relationship): results = [] for element in elements: if inverse_relationship == "types": @@ -140,20 +145,22 @@ class Selector: results.append(relationship.RelatedBuildingElement) return results - def get_class_selector(self, class_selector): + @classmethod + def get_class_selector(cls, class_selector): if class_selector.children[0] == "COBie": - elements = ifcopenshell.util.fm.get_cobie_components(self.file) + elements = ifcopenshell.util.fm.get_cobie_components(cls.file) elif class_selector.children[0] == "COBieType": - elements = ifcopenshell.util.fm.get_cobie_types(self.file) + elements = ifcopenshell.util.fm.get_cobie_types(cls.file) elif class_selector.children[0] == "FMHEM": - elements = ifcopenshell.util.fm.get_fmhem_types(self.file) + elements = ifcopenshell.util.fm.get_fmhem_types(cls.file) else: - elements = self.file.by_type(class_selector.children[0]) + elements = cls.file.by_type(class_selector.children[0]) if len(class_selector.children) > 1 and class_selector.children[1].data == "filter": - return self.filter_elements(elements, class_selector.children[1]) + return cls.filter_elements(elements, class_selector.children[1]) return elements - def filter_elements(self, elements, filter_rule): + @classmethod + def filter_elements(cls, elements, filter_rule): results = [] key = filter_rule.children[0].children[0] if not isinstance(key, str): @@ -163,14 +170,15 @@ class Selector: comparison = filter_rule.children[1].children[0].data value = filter_rule.children[2].children[0][1:-1] for element in elements: - element_value = self.get_element_value(element, key) + element_value = cls.get_element_value(element, key) if element_value is None: continue - if not comparison or self.filter_element(element, element_value, comparison, value): + if not comparison or cls.filter_element(element, element_value, comparison, value): results.append(element) return results - def get_element_value(self, element, key): + @classmethod + def get_element_value(cls, element, key): if "." in key and key.split(".")[0] == "type": try: element = ifcopenshell.util.element.get_type(element) @@ -206,7 +214,8 @@ class Selector: if pset_name in psets and prop in psets[pset_name]: return psets[pset_name][prop] - def filter_element(self, element, element_value, comparison, value): + @classmethod + def filter_element(cls, element, element_value, comparison, value): if comparison == "equal": return str(element_value) == value elif comparison == "contains": @@ -221,5 +230,6 @@ class Selector: return element_value <= float(value) return False - def get_guid_selector(self, guid_selector): - return [self.file.by_id(guid_selector.children[0])] + @classmethod + def get_guid_selector(cls, guid_selector): + return [cls.file.by_id(guid_selector.children[0])] diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py new file mode 100644 index 0000000000..683c3151f3 --- /dev/null +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -0,0 +1,47 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import pytest +import test.bootstrap +import ifcopenshell.api +import ifcopenshell.util.selector as subject + + +class TestSelector(test.bootstrap.IFC4): + def test_selecting_by_class(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcSlab") + assert subject.Selector.parse(self.file, ".IfcWall") == [element] + + def test_selecting_by_globalid(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcSlab") + assert subject.Selector.parse(self.file, f"#{element.GlobalId}") == [element] + + def test_selecting_by_attribute(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + element.Name = "Foobar" + ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcSlab") + assert subject.Selector.parse(self.file, '.IfcElement[Name="Foobar"]') == [element] + assert subject.Selector.parse(self.file, '.IfcElement[Name="Foobaz"]') == [] + + def test_selecting_by_string_property(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": "Bar"}) + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo="Bar"]') == [element] From fa6ec25a39b753795b25045f11c7fe251e884f7c Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 21 Mar 2022 18:03:25 +1100 Subject: [PATCH 41/85] #2082. You can now use the selector to filter by strict data types. --- .../ifcopenshell/util/selector.py | 20 ++++++++++--- .../test/util/test_selector.py | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index b423cf4fbe..f81b369076 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -36,7 +36,7 @@ class Selector: class_selector: "." WORD filter ? filter: "[" filter_key (comparison filter_value)? "]" filter_key: WORD | pset_or_qto - filter_value: ESCAPED_STRING + filter_value: ESCAPED_STRING | SIGNED_FLOAT | SIGNED_INT | BOOLEAN | NULL pset_or_qto: /[A-Za-z0-9_]+/ "." /[A-Za-z0-9_]+/ lfunction: and | or inverse_relationship: types | contains_elements | boundedby @@ -52,6 +52,8 @@ class Selector: equal: "=" morethan: ">" lessthan: "<" + BOOLEAN: "TRUE" | "FALSE" + NULL: "NULL" // Embed common.lark for packaging DIGIT: "0".."9" @@ -168,10 +170,20 @@ class Selector: comparison = value = None if len(filter_rule.children) > 1: comparison = filter_rule.children[1].children[0].data - value = filter_rule.children[2].children[0][1:-1] + token_type = filter_rule.children[2].children[0].type + if token_type == "ESCAPED_STRING": + value = str(filter_rule.children[2].children[0][1:-1]) + elif token_type == "SIGNED_INT": + value = int(filter_rule.children[2].children[0]) + elif token_type == "SIGNED_FLOAT": + value = float(filter_rule.children[2].children[0]) + elif token_type == "BOOLEAN": + value = filter_rule.children[2].children[0] == "TRUE" + elif token_type == "NULL": + value = None for element in elements: element_value = cls.get_element_value(element, key) - if element_value is None: + if element_value is None and value is not None: continue if not comparison or cls.filter_element(element, element_value, comparison, value): results.append(element) @@ -217,7 +229,7 @@ class Selector: @classmethod def filter_element(cls, element, element_value, comparison, value): if comparison == "equal": - return str(element_value) == value + return element_value == value elif comparison == "contains": return value in str(element_value) elif comparison == "morethan": diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index 683c3151f3..54a678349b 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -45,3 +45,31 @@ class TestSelector(test.bootstrap.IFC4): pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": "Bar"}) assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo="Bar"]') == [element] + + def test_selecting_by_integer_property(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": 42}) + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=42]') == [element] + + def test_selecting_by_float_property(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": 4.2}) + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=4.2]') == [element] + + def test_selecting_by_boolean_property(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": True}) + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=TRUE]') == [element] + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": False}) + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=FALSE]') == [element] + + def test_selecting_by_null_property(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": "Bar"}) + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=NULL]') == [] + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": None}) + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=NULL]') == [element] From 96256d365afc16d59156de645f531e7da2d2f0ca Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 22 Mar 2022 10:39:10 +1100 Subject: [PATCH 42/85] Fix bug where you couldn't compare less than or equal to with the selector. --- src/ifcopenshell-python/ifcopenshell/util/selector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index f81b369076..f69356a750 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -48,7 +48,7 @@ class Selector: comparison: contains | morethanequalto | lessthanequalto | equal | morethan | lessthan contains: "*=" morethanequalto: ">=" - lessthanequalto: "<" + lessthanequalto: "<=" equal: "=" morethan: ">" lessthan: "<" From efb89acfa3a4c75e3feebaab85ac5b0d3814de22 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 22 Mar 2022 10:40:05 +1100 Subject: [PATCH 43/85] You can now use a "not" operator when selecting elements --- .../ifcopenshell/util/selector.py | 17 +++++++++++------ .../test/util/test_selector.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index f69356a750..cb524270f3 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -45,7 +45,8 @@ class Selector: boundedby: "@@" and: "&" or: "|" - comparison: contains | morethanequalto | lessthanequalto | equal | morethan | lessthan + not: "!" + comparison: (not)* (contains | morethanequalto | lessthanequalto | equal | morethan | lessthan) contains: "*=" morethanequalto: ">=" lessthanequalto: "<=" @@ -170,6 +171,8 @@ class Selector: comparison = value = None if len(filter_rule.children) > 1: comparison = filter_rule.children[1].children[0].data + if comparison == "not": + comparison += filter_rule.children[1].children[1].data token_type = filter_rule.children[2].children[0].type if token_type == "ESCAPED_STRING": value = str(filter_rule.children[2].children[0][1:-1]) @@ -228,18 +231,20 @@ class Selector: @classmethod def filter_element(cls, element, element_value, comparison, value): - if comparison == "equal": + if comparison.startswith("not"): + return not cls.filter_element(element, element_value, comparison[3:], value) + elif comparison == "equal": return element_value == value elif comparison == "contains": return value in str(element_value) elif comparison == "morethan": - return element_value > float(value) + return element_value > value elif comparison == "lessthan": - return element_value < float(value) + return element_value < value elif comparison == "morethanequalto": - return element_value >= float(value) + return element_value >= value elif comparison == "lessthanequalto": - return element_value <= float(value) + return element_value <= value return False @classmethod diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index 54a678349b..8f655a686f 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -73,3 +73,20 @@ class TestSelector(test.bootstrap.IFC4): assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=NULL]') == [] ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": None}) assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=NULL]') == [element] + + def test_comparing_by_not_equal(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + element.Name = "Foobar" + assert subject.Selector.parse(self.file, '.IfcElement[Name!="Foobaz"]') == [element] + assert subject.Selector.parse(self.file, '.IfcElement[Name!="Foobar"]') == [] + + def test_comparing_by_ranges(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": 4.2}) + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo>2]') == [element] + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo>20]') == [] + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo<2]') == [] + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo<20]') == [element] + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo>=4.2]') == [element] + assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo<=4.2]') == [element] From eaa3301b8fef20956278c2ecc0a2c6628b9772e4 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 22 Mar 2022 10:49:48 +1100 Subject: [PATCH 44/85] New support for filtering elements by checking values against an enumeration --- .../ifcopenshell/util/selector.py | 5 ++++- .../test/util/test_selector.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index cb524270f3..955e6a4b06 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -46,7 +46,8 @@ class Selector: and: "&" or: "|" not: "!" - comparison: (not)* (contains | morethanequalto | lessthanequalto | equal | morethan | lessthan) + comparison: (not)* (oneof | contains | morethanequalto | lessthanequalto | equal | morethan | lessthan) + oneof: "%=" contains: "*=" morethanequalto: ">=" lessthanequalto: "<=" @@ -245,6 +246,8 @@ class Selector: return element_value >= value elif comparison == "lessthanequalto": return element_value <= value + elif comparison == "oneof": + return element_value in value.split(",") return False @classmethod diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index 8f655a686f..f7e9b071d4 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -90,3 +90,19 @@ class TestSelector(test.bootstrap.IFC4): assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo<20]') == [element] assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo>=4.2]') == [element] assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo<=4.2]') == [element] + + def test_comparing_if_value_contains_a_wildcard_string(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + element.Name = "Foobar" + assert subject.Selector.parse(self.file, '.IfcElement[Name*="Foo"]') == [element] + assert subject.Selector.parse(self.file, '.IfcElement[Name*="oba"]') == [element] + assert subject.Selector.parse(self.file, '.IfcElement[Name*="abc"]') == [] + + def test_comparing_if_value_is_in_a_list(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + element.Name = "Foobar" + assert subject.Selector.parse(self.file, '.IfcElement[Name%="Foobar,Foobaz"]') == [element] + element.Name = "Foobaz" + assert subject.Selector.parse(self.file, '.IfcElement[Name%="Foobar,Foobaz"]') == [element] + element.Name = "Foobat" + assert subject.Selector.parse(self.file, '.IfcElement[Name%="Foobar,Foobaz"]') == [] From 18c9a57bead21265184d2fe6676c6c37148cb9b3 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 22 Mar 2022 11:23:13 +1100 Subject: [PATCH 45/85] Using the selector now supports recursive decomposition in the spatial hierarchy --- .../ifcopenshell/util/selector.py | 13 +++--- .../test/util/test_selector.py | 41 +++++++++++++------ 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index 955e6a4b06..6ec36b1878 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -39,10 +39,10 @@ class Selector: filter_value: ESCAPED_STRING | SIGNED_FLOAT | SIGNED_INT | BOOLEAN | NULL pset_or_qto: /[A-Za-z0-9_]+/ "." /[A-Za-z0-9_]+/ lfunction: and | or - inverse_relationship: types | contains_elements | boundedby + inverse_relationship: types | decomposed_by | bounded_by types: "*" - contains_elements: "@" - boundedby: "@@" + decomposed_by: "@" + bounded_by: "@@" and: "&" or: "|" not: "!" @@ -141,10 +141,9 @@ class Selector: results.extend(element.Types[0].RelatedObjects) elif hasattr(element, "ObjectTypeOf") and element.ObjectTypeOf: results.extend(element.ObjectTypeOf[0].RelatedObjects) - elif inverse_relationship == "contains_elements" and hasattr(element, "ContainsElements"): - for relationship in element.ContainsElements: - results.extend(relationship.RelatedElements) - elif inverse_relationship == "boundedby" and hasattr(element, "BoundedBy"): + elif inverse_relationship == "decomposed_by": + results.extend(ifcopenshell.util.element.get_decomposition(element)) + elif inverse_relationship == "bounded_by" and hasattr(element, "BoundedBy"): for relationship in element.BoundedBy: results.append(relationship.RelatedBuildingElement) return results diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index f7e9b071d4..c7805f3871 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -50,29 +50,29 @@ class TestSelector(test.bootstrap.IFC4): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": 42}) - assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=42]') == [element] + assert subject.Selector.parse(self.file, ".IfcElement[Foo_Bar.Foo=42]") == [element] def test_selecting_by_float_property(self): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": 4.2}) - assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=4.2]') == [element] + assert subject.Selector.parse(self.file, ".IfcElement[Foo_Bar.Foo=4.2]") == [element] def test_selecting_by_boolean_property(self): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": True}) - assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=TRUE]') == [element] + assert subject.Selector.parse(self.file, ".IfcElement[Foo_Bar.Foo=TRUE]") == [element] ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": False}) - assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=FALSE]') == [element] + assert subject.Selector.parse(self.file, ".IfcElement[Foo_Bar.Foo=FALSE]") == [element] def test_selecting_by_null_property(self): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": "Bar"}) - assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=NULL]') == [] + assert subject.Selector.parse(self.file, ".IfcElement[Foo_Bar.Foo=NULL]") == [] ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": None}) - assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo=NULL]') == [element] + assert subject.Selector.parse(self.file, ".IfcElement[Foo_Bar.Foo=NULL]") == [element] def test_comparing_by_not_equal(self): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") @@ -84,12 +84,12 @@ class TestSelector(test.bootstrap.IFC4): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Foo": 4.2}) - assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo>2]') == [element] - assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo>20]') == [] - assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo<2]') == [] - assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo<20]') == [element] - assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo>=4.2]') == [element] - assert subject.Selector.parse(self.file, '.IfcElement[Foo_Bar.Foo<=4.2]') == [element] + assert subject.Selector.parse(self.file, ".IfcElement[Foo_Bar.Foo>2]") == [element] + assert subject.Selector.parse(self.file, ".IfcElement[Foo_Bar.Foo>20]") == [] + assert subject.Selector.parse(self.file, ".IfcElement[Foo_Bar.Foo<2]") == [] + assert subject.Selector.parse(self.file, ".IfcElement[Foo_Bar.Foo<20]") == [element] + assert subject.Selector.parse(self.file, ".IfcElement[Foo_Bar.Foo>=4.2]") == [element] + assert subject.Selector.parse(self.file, ".IfcElement[Foo_Bar.Foo<=4.2]") == [element] def test_comparing_if_value_contains_a_wildcard_string(self): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") @@ -106,3 +106,20 @@ class TestSelector(test.bootstrap.IFC4): assert subject.Selector.parse(self.file, '.IfcElement[Name%="Foobar,Foobaz"]') == [element] element.Name = "Foobat" assert subject.Selector.parse(self.file, '.IfcElement[Name%="Foobar,Foobaz"]') == [] + + def test_getting_occurrences_of_a_filtered_type(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + element_type = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWallType") + ifcopenshell.api.run("type.assign_type", self.file, related_object=element, relating_type=element_type) + element2 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + element_type = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWallType") + ifcopenshell.api.run("type.assign_type", self.file, related_object=element2, relating_type=element_type) + assert subject.Selector.parse(self.file, "* .IfcWallType") == [element, element2] + + def test_getting_decomposition_of_a_filtered_type(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + subelement = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcMember") + building = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcBuilding") + ifcopenshell.api.run("spatial.assign_container", self.file, product=element, relating_structure=building) + ifcopenshell.api.run("aggregate.assign_object", self.file, product=subelement, relating_object=element) + assert subject.Selector.parse(self.file, "@ .IfcBuilding") == [element, subelement] From c072554f21eb166b265d663ac07e9c0d5f1184c1 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 22 Mar 2022 11:32:40 +1100 Subject: [PATCH 46/85] You can now use the selector with a prefiltered list of elements --- .../ifcopenshell/util/selector.py | 8 ++++++-- src/ifcopenshell-python/test/bootstrap.py | 4 ++++ src/ifcopenshell-python/test/util/test_selector.py | 14 ++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index 6ec36b1878..419937aac3 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -24,8 +24,9 @@ import lark class Selector: @classmethod - def parse(cls, ifc_file, query): + def parse(cls, ifc_file, query, elements=None): cls.file = ifc_file + cls.elements = elements l = lark.Lark( """start: query (lfunction query)* @@ -157,7 +158,10 @@ class Selector: elif class_selector.children[0] == "FMHEM": elements = ifcopenshell.util.fm.get_fmhem_types(cls.file) else: - elements = cls.file.by_type(class_selector.children[0]) + if cls.elements is None: + elements = cls.file.by_type(class_selector.children[0]) + else: + elements = [e for e in cls.elements if e.is_a(class_selector.children[0])] if len(class_selector.children) > 1 and class_selector.children[1].data == "filter": return cls.filter_elements(elements, class_selector.children[1]) return elements diff --git a/src/ifcopenshell-python/test/bootstrap.py b/src/ifcopenshell-python/test/bootstrap.py index a5b6905f31..b87d30855b 100644 --- a/src/ifcopenshell-python/test/bootstrap.py +++ b/src/ifcopenshell-python/test/bootstrap.py @@ -28,6 +28,8 @@ class IFC4: self.file = ifcopenshell.api.run("project.create_file") ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + ifcopenshell.api.pre_listeners = {} + ifcopenshell.api.post_listeners = {} class IFC2X3: @@ -36,3 +38,5 @@ class IFC2X3: self.file = ifcopenshell.api.run("project.create_file", version="IFC2X3") ifcopenshell.api.owner.settings.get_user = lambda ifc: ifc.createIfcPersonAndOrganization() ifcopenshell.api.owner.settings.get_application = lambda ifc: ifc.createIfcApplication() + ifcopenshell.api.pre_listeners = {} + ifcopenshell.api.post_listeners = {} diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index c7805f3871..3c138098cc 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -112,9 +112,9 @@ class TestSelector(test.bootstrap.IFC4): element_type = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWallType") ifcopenshell.api.run("type.assign_type", self.file, related_object=element, relating_type=element_type) element2 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") - element_type = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWallType") - ifcopenshell.api.run("type.assign_type", self.file, related_object=element2, relating_type=element_type) - assert subject.Selector.parse(self.file, "* .IfcWallType") == [element, element2] + element_type2 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWallType") + ifcopenshell.api.run("type.assign_type", self.file, related_object=element2, relating_type=element_type2) + assert set(subject.Selector.parse(self.file, "* .IfcWallType")) == {element, element2} def test_getting_decomposition_of_a_filtered_type(self): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") @@ -122,4 +122,10 @@ class TestSelector(test.bootstrap.IFC4): building = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcBuilding") ifcopenshell.api.run("spatial.assign_container", self.file, product=element, relating_structure=building) ifcopenshell.api.run("aggregate.assign_object", self.file, product=subelement, relating_object=element) - assert subject.Selector.parse(self.file, "@ .IfcBuilding") == [element, subelement] + assert set(subject.Selector.parse(self.file, "@ .IfcBuilding")) == {element, subelement} + + def test_selecting_elements_from_a_prefiltered_list(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + element2 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcSlab") + assert subject.Selector.parse(self.file, ".IfcWall", elements=[element]) + assert not subject.Selector.parse(self.file, ".IfcWall", elements=[element2]) From 0c8273eb70a8049087c9a794c718c4c41c4b60f6 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 22 Mar 2022 11:47:37 +1100 Subject: [PATCH 47/85] Minor fix --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5631f49584..e4347d993f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,7 @@ jobs: sudo /usr/bin/python -m pip install src/bcf sudo /usr/bin/python -m pip install pytest sudo /usr/bin/python -m pip install isodate + sudo /usr/bin/python -m pip install lark - name: Test run: | From 9b9f0058f931830e33f77cba4ccbe472ec67dda5 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 22 Mar 2022 12:50:10 +1100 Subject: [PATCH 48/85] The material manager now shows total elements used by a material --- .../blenderbim/bim/module/material/prop.py | 1 + .../blenderbim/bim/module/material/ui.py | 3 ++ src/blenderbim/blenderbim/tool/material.py | 1 + src/blenderbim/test/tool/test_material.py | 5 ++ .../ifcopenshell/util/element.py | 23 ++++++++ .../test/util/test_element.py | 54 +++++++++++++++++++ 6 files changed, 87 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/material/prop.py b/src/blenderbim/blenderbim/bim/module/material/prop.py index 9789dd18e7..4b83a1e042 100644 --- a/src/blenderbim/blenderbim/bim/module/material/prop.py +++ b/src/blenderbim/blenderbim/bim/module/material/prop.py @@ -114,6 +114,7 @@ def get_material_types(self, context): class Material(PropertyGroup): name: StringProperty(name="Name") ifc_definition_id: IntProperty(name="IFC Definition ID") + total_elements: IntProperty(name="Total Elements") class BIMMaterialProperties(PropertyGroup): diff --git a/src/blenderbim/blenderbim/bim/module/material/ui.py b/src/blenderbim/blenderbim/bim/module/material/ui.py index 7ea70c5813..e6e6418fb5 100644 --- a/src/blenderbim/blenderbim/bim/module/material/ui.py +++ b/src/blenderbim/blenderbim/bim/module/material/ui.py @@ -389,3 +389,6 @@ class BIM_UL_materials(UIList): if item: row = layout.row(align=True) row.label(text=item.name) + row2 = row.row() + row2.alignment = "RIGHT" + row2.label(text=str(item.total_elements)) diff --git a/src/blenderbim/blenderbim/tool/material.py b/src/blenderbim/blenderbim/tool/material.py index 0e27d54803..b2193b5dc2 100644 --- a/src/blenderbim/blenderbim/tool/material.py +++ b/src/blenderbim/blenderbim/tool/material.py @@ -61,6 +61,7 @@ class Material(blenderbim.core.tool.Material): new.name = "Unnamed" else: new.name = material.Name or "Unnamed" + new.total_elements = len(set(ifcopenshell.util.element.get_elements_by_material(tool.Ifc.get(), material))) @classmethod def is_editing_materials(cls): diff --git a/src/blenderbim/test/tool/test_material.py b/src/blenderbim/test/tool/test_material.py index 093aabec41..1637a00440 100644 --- a/src/blenderbim/test/tool/test_material.py +++ b/src/blenderbim/test/tool/test_material.py @@ -82,6 +82,7 @@ class TestImportMaterialDefinitions(NewFile): props = bpy.context.scene.BIMMaterialProperties assert props.materials[0].ifc_definition_id == material.id() assert props.materials[0].name == "Name" + assert props.materials[0].total_elements == 0 def test_import_material_layer_sets(self): ifc = ifcopenshell.file() @@ -91,6 +92,7 @@ class TestImportMaterialDefinitions(NewFile): props = bpy.context.scene.BIMMaterialProperties assert props.materials[0].ifc_definition_id == material.id() assert props.materials[0].name == "Name" + assert props.materials[0].total_elements == 0 def test_import_material_profile_sets(self): ifc = ifcopenshell.file() @@ -100,6 +102,7 @@ class TestImportMaterialDefinitions(NewFile): props = bpy.context.scene.BIMMaterialProperties assert props.materials[0].ifc_definition_id == material.id() assert props.materials[0].name == "Name" + assert props.materials[0].total_elements == 0 def test_import_material_constituent_sets(self): ifc = ifcopenshell.file() @@ -109,6 +112,7 @@ class TestImportMaterialDefinitions(NewFile): props = bpy.context.scene.BIMMaterialProperties assert props.materials[0].ifc_definition_id == material.id() assert props.materials[0].name == "Name" + assert props.materials[0].total_elements == 0 def test_import_material_lists(self): ifc = ifcopenshell.file() @@ -118,6 +122,7 @@ class TestImportMaterialDefinitions(NewFile): props = bpy.context.scene.BIMMaterialProperties assert props.materials[0].ifc_definition_id == material.id() assert props.materials[0].name == "Unnamed" + assert props.materials[0].total_elements == 0 class TestIsEditingMaterials(NewFile): diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index 0f7b1b1c66..caa618d373 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -133,6 +133,29 @@ def get_material(element, should_skip_usage=False): return get_material(relating_type, should_skip_usage) +def get_elements_by_material(ifc_file, material): + results = [] + for inverse in ifc_file.get_inverse(material): + if inverse.is_a("IfcRelAssociatesMaterial"): + results.extend(inverse.RelatedObjects) + elif inverse.is_a("IfcMaterialLayer"): + for material_set in inverse.ToMaterialLayerSet: + results.extend(get_elements_by_material(ifc_file, material_set)) + elif inverse.is_a("IfcMaterialProfile"): + for material_set in inverse.ToMaterialProfileSet: + results.extend(get_elements_by_material(ifc_file, material_set)) + elif inverse.is_a("IfcMaterialConstituent"): + for material_set in inverse.ToMaterialConstituentSet: + results.extend(get_elements_by_material(ifc_file, material_set)) + elif inverse.is_a("IfcMaterialLayerSetUsage"): + results.extend(get_elements_by_material(ifc_file, inverse)) + elif inverse.is_a("IfcMaterialProfileSetUsage"): + results.extend(get_elements_by_material(ifc_file, inverse)) + elif inverse.is_a("IfcMaterialList"): + results.extend(get_elements_by_material(ifc_file, inverse)) + return results + + def get_layers(ifc_file, element): layers = [] representations = [] diff --git a/src/ifcopenshell-python/test/util/test_element.py b/src/ifcopenshell-python/test/util/test_element.py index 3ad1ecc4cd..8a14e6993f 100644 --- a/src/ifcopenshell-python/test/util/test_element.py +++ b/src/ifcopenshell-python/test/util/test_element.py @@ -268,6 +268,60 @@ class TestGetMaterial(test.bootstrap.IFC4): assert subject.get_material(element) == material +class TestGetElementsByMaterial(test.bootstrap.IFC4): + def test_getting_elements_of_a_material(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + material = ifcopenshell.api.run("material.add_material", self.file) + ifcopenshell.api.run("material.assign_material", self.file, product=element, material=material) + assert subject.get_elements_by_material(self.file, material) == [element] + + def test_getting_elements_of_a_material_layer_set(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + element_type = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWallType") + ifcopenshell.api.run("type.assign_type", self.file, related_object=element, relating_type=element_type) + material = ifcopenshell.api.run("material.add_material", self.file) + material_set = ifcopenshell.api.run("material.add_material_set", self.file, set_type="IfcMaterialLayerSet") + ifcopenshell.api.run("material.add_layer", self.file, layer_set=material_set, material=material) + ifcopenshell.api.run("material.assign_material", self.file, product=element_type, material=material_set) + ifcopenshell.api.run("material.assign_material", self.file, product=element, type="IfcMaterialLayerSetUsage") + usage = self.file.by_type("IfcMaterialLayerSetUsage")[0] + assert set(subject.get_elements_by_material(self.file, material)) == {element, element_type} + assert set(subject.get_elements_by_material(self.file, material_set)) == {element, element_type} + assert set(subject.get_elements_by_material(self.file, usage)) == {element} + + def test_getting_elements_of_a_material_profile_set(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + element_type = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWallType") + ifcopenshell.api.run("type.assign_type", self.file, related_object=element, relating_type=element_type) + material = ifcopenshell.api.run("material.add_material", self.file) + material_set = ifcopenshell.api.run("material.add_material_set", self.file, set_type="IfcMaterialProfileSet") + ifcopenshell.api.run("material.add_profile", self.file, profile_set=material_set, material=material) + ifcopenshell.api.run("material.assign_material", self.file, product=element_type, material=material_set) + ifcopenshell.api.run("material.assign_material", self.file, product=element, type="IfcMaterialProfileSetUsage") + usage = self.file.by_type("IfcMaterialProfileSetUsage")[0] + assert set(subject.get_elements_by_material(self.file, material)) == {element, element_type} + assert set(subject.get_elements_by_material(self.file, material_set)) == {element, element_type} + assert set(subject.get_elements_by_material(self.file, usage)) == {element} + + def test_getting_elements_of_a_material_constituent_set(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + material = ifcopenshell.api.run("material.add_material", self.file) + material_set = ifcopenshell.api.run("material.add_material_set", self.file, set_type="IfcMaterialConstituentSet") + ifcopenshell.api.run("material.add_constituent", self.file, constituent_set=material_set, material=material) + ifcopenshell.api.run("material.assign_material", self.file, product=element, material=material_set) + assert set(subject.get_elements_by_material(self.file, material)) == {element} + assert set(subject.get_elements_by_material(self.file, material_set)) == {element} + + def test_getting_elements_of_a_material_list(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + material = ifcopenshell.api.run("material.add_material", self.file) + material_set = ifcopenshell.api.run("material.add_material_set", self.file, set_type="IfcMaterialList") + ifcopenshell.api.run("material.add_list_item", self.file, material_list=material_set, material=material) + ifcopenshell.api.run("material.assign_material", self.file, product=element, material=material_set) + assert set(subject.get_elements_by_material(self.file, material)) == {element} + assert set(subject.get_elements_by_material(self.file, material_set)) == {element} + + class TestGetlayers(test.bootstrap.IFC4): def test_getting_the_layer_of_a_product_representation(self): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") From 0bba31e5f9f46f07f5871bcdad5f245fd73620b4 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 22 Mar 2022 13:25:26 +1100 Subject: [PATCH 49/85] You can now select elements by material in the material manager. --- .../bim/module/material/__init__.py | 1 + .../blenderbim/bim/module/material/data.py | 6 +++++ .../bim/module/material/operator.py | 10 +++++++++ .../blenderbim/bim/module/material/ui.py | 4 ++++ src/blenderbim/blenderbim/core/material.py | 4 ++++ src/blenderbim/blenderbim/core/tool.py | 2 ++ src/blenderbim/blenderbim/tool/material.py | 11 ++++++++++ .../test/bim/feature/material.feature | 9 ++++++++ src/blenderbim/test/core/test_material.py | 7 ++++++ src/blenderbim/test/tool/test_material.py | 22 +++++++++++++++++++ 10 files changed, 76 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/material/__init__.py b/src/blenderbim/blenderbim/bim/module/material/__init__.py index 47bd7b6d0f..8ba682ae5c 100644 --- a/src/blenderbim/blenderbim/bim/module/material/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/material/__init__.py @@ -44,6 +44,7 @@ classes = ( operator.RemoveMaterialSet, operator.RemoveProfile, operator.ReorderMaterialSetItem, + operator.SelectByMaterial, operator.UnassignMaterial, operator.UnlinkMaterial, prop.Material, diff --git a/src/blenderbim/blenderbim/bim/module/material/data.py b/src/blenderbim/blenderbim/bim/module/material/data.py index 9d758e685a..8929bd00bc 100644 --- a/src/blenderbim/blenderbim/bim/module/material/data.py +++ b/src/blenderbim/blenderbim/bim/module/material/data.py @@ -41,6 +41,12 @@ class MaterialsData: @classmethod def total_materials(cls): + if tool.Ifc.get_schema() == "IFC2X3": + return ( + len(tool.Ifc.get().by_type("IfcMaterial")) + + len(tool.Ifc.get().by_type("IfcMaterialLayerSet")) + + len(tool.Ifc.get().by_type("IfcMaterialList")) + ) return ( len(tool.Ifc.get().by_type("IfcMaterial")) + len(tool.Ifc.get().by_type("IfcMaterialConstituentSet")) diff --git a/src/blenderbim/blenderbim/bim/module/material/operator.py b/src/blenderbim/blenderbim/bim/module/material/operator.py index e2d0542ae8..37e4781581 100644 --- a/src/blenderbim/blenderbim/bim/module/material/operator.py +++ b/src/blenderbim/blenderbim/bim/module/material/operator.py @@ -49,6 +49,16 @@ class DisableEditingMaterials(bpy.types.Operator, tool.Ifc.Operator): core.disable_editing_materials(tool.Material) +class SelectByMaterial(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.select_by_material" + bl_label = "Select By Material" + bl_options = {"REGISTER", "UNDO"} + material: bpy.props.IntProperty() + + def _execute(self, context): + core.select_by_material(tool.Material, material=tool.Ifc.get().by_id(self.material)) + + class AssignParameterizedProfile(bpy.types.Operator): bl_idname = "bim.assign_parameterized_profile" bl_label = "Assign Parameterized Profile" diff --git a/src/blenderbim/blenderbim/bim/module/material/ui.py b/src/blenderbim/blenderbim/bim/module/material/ui.py index e6e6418fb5..7791466af6 100644 --- a/src/blenderbim/blenderbim/bim/module/material/ui.py +++ b/src/blenderbim/blenderbim/bim/module/material/ui.py @@ -61,11 +61,15 @@ class BIM_PT_materials(Panel): row.operator("bim.add_material", text="", icon="ADD") if self.props.materials and self.props.active_material_index < len(self.props.materials): material = self.props.materials[self.props.active_material_index] + op = row.operator("bim.select_by_material", text="", icon="RESTRICT_SELECT_OFF") + op.material = material.ifc_definition_id row.operator("bim.remove_material", text="", icon="X").material = material.ifc_definition_id else: row.operator("bim.add_material_set", text="", icon="ADD").set_type = self.props.material_type if self.props.materials and self.props.active_material_index < len(self.props.materials): material = self.props.materials[self.props.active_material_index] + op = row.operator("bim.select_by_material", text="", icon="RESTRICT_SELECT_OFF") + op.material = material.ifc_definition_id row.operator("bim.remove_material_set", text="", icon="X").material = material.ifc_definition_id self.layout.template_list("BIM_UL_materials", "", self.props, "materials", self.props, "active_material_index") diff --git a/src/blenderbim/blenderbim/core/material.py b/src/blenderbim/blenderbim/core/material.py index 15dd2442e8..48a9136ca8 100644 --- a/src/blenderbim/blenderbim/core/material.py +++ b/src/blenderbim/blenderbim/core/material.py @@ -66,3 +66,7 @@ def load_materials(material, material_type): def disable_editing_materials(material): material.disable_editing_materials() + + +def select_by_material(material_tool, material=None): + material_tool.select_elements(material_tool.get_elements_by_material(material)) diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index 21f8796d6c..b18e73000a 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -280,9 +280,11 @@ class Material: def disable_editing_materials(cls): pass def enable_editing_materials(cls): pass def get_active_material_type(cls): pass + def get_elements_by_material(cls, material): pass def get_name(cls, obj): pass def import_material_definitions(cls, material_type): pass def is_editing_materials(cls): pass + def select_elements(cls, elements): pass @interface diff --git a/src/blenderbim/blenderbim/tool/material.py b/src/blenderbim/blenderbim/tool/material.py index b2193b5dc2..9ac4345587 100644 --- a/src/blenderbim/blenderbim/tool/material.py +++ b/src/blenderbim/blenderbim/tool/material.py @@ -44,6 +44,10 @@ class Material(blenderbim.core.tool.Material): def get_active_material_type(cls): return bpy.context.scene.BIMMaterialProperties.material_type + @classmethod + def get_elements_by_material(cls, material): + return set(ifcopenshell.util.element.get_elements_by_material(tool.Ifc.get(), material)) + @classmethod def get_name(cls, obj): return obj.name @@ -66,3 +70,10 @@ class Material(blenderbim.core.tool.Material): @classmethod def is_editing_materials(cls): return bpy.context.scene.BIMMaterialProperties.is_editing + + @classmethod + def select_elements(cls, elements): + for element in elements: + obj = tool.Ifc.get_object(element) + if obj: + obj.select_set(True) diff --git a/src/blenderbim/test/bim/feature/material.feature b/src/blenderbim/test/bim/feature/material.feature index cbea52096b..8d8b87694d 100644 --- a/src/blenderbim/test/bim/feature/material.feature +++ b/src/blenderbim/test/bim/feature/material.feature @@ -110,3 +110,12 @@ Scenario: Assign material - Assign a material profile set When I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialProfileSet" And I press "bim.assign_material" Then nothing happens + +Scenario: Select by material +Scenario: Load materials - then add material + Given an empty IFC project + And I press "bim.load_materials" + And I press "bim.add_material(obj='')" + And the variable "material" is "{ifc}.by_type('IfcMaterial')[0].id()" + When I press "bim.select_by_material(material={material})" + Then nothing happens diff --git a/src/blenderbim/test/core/test_material.py b/src/blenderbim/test/core/test_material.py index accf5b50b7..e589c55357 100644 --- a/src/blenderbim/test/core/test_material.py +++ b/src/blenderbim/test/core/test_material.py @@ -147,3 +147,10 @@ class TestDisableEditingMaterials: def test_run(self, material): material.disable_editing_materials().should_be_called() subject.disable_editing_materials(material) + + +class TestSelectByMaterial: + def test_run(self, material): + material.get_elements_by_material("material").should_be_called().will_return("elements") + material.select_elements("elements").should_be_called() + subject.select_by_material(material, material="material") diff --git a/src/blenderbim/test/tool/test_material.py b/src/blenderbim/test/tool/test_material.py index 1637a00440..4ce5af138f 100644 --- a/src/blenderbim/test/tool/test_material.py +++ b/src/blenderbim/test/tool/test_material.py @@ -68,6 +68,16 @@ class TestGetActiveMaterialType(NewFile): assert subject.get_active_material_type() == "IfcMaterialLayerSet" +class TestGetElementsByMaterial(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") + material = ifcopenshell.api.run("material.add_material", ifc) + ifcopenshell.api.run("material.assign_material", ifc, product=element, material=material) + assert subject.get_elements_by_material(material) == {element} + + class TestGetName(NewFile): def test_run(self): assert subject.get_name(bpy.data.materials.new("Material")) == "Material" @@ -131,3 +141,15 @@ class TestIsEditingMaterials(NewFile): subject.is_editing_materials() is False bpy.context.scene.BIMMaterialProperties.is_editing = True subject.is_editing_materials() is True + + +class TestSelectElements(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc().set(ifc) + element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcPump") + obj = bpy.data.objects.new("Object", None) + bpy.context.scene.collection.objects.link(obj) + tool.Ifc.link(element, obj) + subject.select_elements([element]) + assert obj in bpy.context.selected_objects From 533111a1fbaeae361513ce132993053cb8c32591 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 22 Mar 2022 14:30:45 +1100 Subject: [PATCH 50/85] #2099. Materials are now sorted alphabetically. --- .../blenderbim/bim/module/material/data.py | 17 +++++++++++++++++ .../blenderbim/bim/module/material/prop.py | 19 +++++++------------ .../blenderbim/bim/module/material/ui.py | 7 +++++-- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/material/data.py b/src/blenderbim/blenderbim/bim/module/material/data.py index 8929bd00bc..a327e31ab8 100644 --- a/src/blenderbim/blenderbim/bim/module/material/data.py +++ b/src/blenderbim/blenderbim/bim/module/material/data.py @@ -25,6 +25,7 @@ import blenderbim.tool as tool def refresh(): MaterialsData.is_loaded = False + ObjectMaterialData.is_loaded = False class MaterialsData: @@ -67,3 +68,19 @@ class MaterialsData: if tool.Ifc.get_schema() == "IFC2X3": material_types = ["IfcMaterial", "IfcMaterialLayerSet", "IfcMaterialList"] return [(m, m, "") for m in material_types] + + +class ObjectMaterialData: + data = {} + is_loaded = False + + @classmethod + def load(cls): + cls.data = { + "materials": cls.materials(), + } + cls.is_loaded = True + + @classmethod + def materials(cls): + return sorted([(str(m.id()), m.Name or "Unnamed", "") for m in tool.Ifc.get().by_type("IfcMaterial")], key=lambda x: x[1]) diff --git a/src/blenderbim/blenderbim/bim/module/material/prop.py b/src/blenderbim/blenderbim/bim/module/material/prop.py index 4b83a1e042..349d132452 100644 --- a/src/blenderbim/blenderbim/bim/module/material/prop.py +++ b/src/blenderbim/blenderbim/bim/module/material/prop.py @@ -18,7 +18,7 @@ import bpy from ifcopenshell.api.material.data import Data -from blenderbim.bim.module.material.data import MaterialsData +from blenderbim.bim.module.material.data import MaterialsData, ObjectMaterialData from blenderbim.bim.ifc import IfcStore from blenderbim.bim.prop import StrProperty, Attribute from bpy.types import PropertyGroup @@ -33,18 +33,15 @@ from bpy.props import ( CollectionProperty, ) -materials_enum = [] materialtypes_enum = [] profileclasses_enum = [] parameterizedprofileclasses_enum = [] def purge(): - global materials_enum global materialtypes_enum global profileclasses_enum global parameterizedprofileclasses_enum - materials_enum = [] materialtypes_enum = [] profileclasses_enum = [] parameterizedprofileclasses_enum = [] @@ -78,12 +75,10 @@ def getParameterizedProfileClasses(self, context): return parameterizedprofileclasses_enum -def getMaterials(self, context): - global materials_enum - if len(materials_enum) == 0 and IfcStore.get_file(): - materials_enum.clear() - materials_enum = [(str(m_id), m["Name"], "") for m_id, m in Data.materials.items()] - return materials_enum +def get_materials(self, context): + if not ObjectMaterialData.is_loaded: + ObjectMaterialData.load() + return ObjectMaterialData.data["materials"] def getMaterialTypes(self, context): @@ -126,7 +121,7 @@ class BIMMaterialProperties(PropertyGroup): class BIMObjectMaterialProperties(PropertyGroup): material_type: EnumProperty(items=getMaterialTypes, name="Material Type") - material: EnumProperty(items=getMaterials, name="Material") + material: EnumProperty(items=get_materials, name="Material") is_editing: BoolProperty(name="Is Editing", default=False) material_set_usage_attributes: CollectionProperty(name="Material Set Usage Attributes", type=Attribute) material_set_attributes: CollectionProperty(name="Material Set Attributes", type=Attribute) @@ -135,7 +130,7 @@ class BIMObjectMaterialProperties(PropertyGroup): material_set_item_profile_attributes: CollectionProperty( name="Material Set Item Profile Attributes", type=Attribute ) - material_set_item_material: EnumProperty(items=getMaterials, name="Material") + material_set_item_material: EnumProperty(items=get_materials, name="Material") profile_classes: EnumProperty(items=getProfileClasses, name="Profile Classes") parameterized_profile_classes: EnumProperty( items=getParameterizedProfileClasses, name="Parameterized Profile Classes" diff --git a/src/blenderbim/blenderbim/bim/module/material/ui.py b/src/blenderbim/blenderbim/bim/module/material/ui.py index 7791466af6..818d59cedb 100644 --- a/src/blenderbim/blenderbim/bim/module/material/ui.py +++ b/src/blenderbim/blenderbim/bim/module/material/ui.py @@ -22,7 +22,7 @@ from ifcopenshell.api.material.data import Data from ifcopenshell.api.profile.data import Data as ProfileData from blenderbim.bim.ifc import IfcStore from blenderbim.bim.helper import draw_attributes -from blenderbim.bim.module.material.data import MaterialsData +from blenderbim.bim.module.material.data import MaterialsData, ObjectMaterialData class BIM_PT_materials(Panel): @@ -119,6 +119,9 @@ class BIM_PT_object_material(Panel): return True def draw(self, context): + if not ObjectMaterialData.is_loaded: + ObjectMaterialData.load() + self.file = IfcStore.get_file() self.oprops = context.active_object.BIMObjectProperties self.props = context.active_object.BIMObjectMaterialProperties @@ -130,7 +133,7 @@ class BIM_PT_object_material(Panel): ProfileData.load(self.file) self.product_data = Data.products[self.oprops.ifc_definition_id] - if not Data.materials: + if not ObjectMaterialData.data["materials"]: row = self.layout.row(align=True) row.label(text="No Materials Available") row.operator("bim.add_material", icon="ADD", text="").obj = "" From 43d0c098a0921c5de47d1c0903a3bfb482c6208f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 22 Mar 2022 17:27:38 +1100 Subject: [PATCH 51/85] #2089. Fix and simplify OffsetObjectPlacement recipe for multiple absolute placements. --- .../recipes/OffsetObjectPlacements.py | 81 ++++++++----------- 1 file changed, 32 insertions(+), 49 deletions(-) diff --git a/src/ifcpatch/ifcpatch/recipes/OffsetObjectPlacements.py b/src/ifcpatch/ifcpatch/recipes/OffsetObjectPlacements.py index c399a3f574..0f8afd40d6 100644 --- a/src/ifcpatch/ifcpatch/recipes/OffsetObjectPlacements.py +++ b/src/ifcpatch/ifcpatch/recipes/OffsetObjectPlacements.py @@ -17,6 +17,9 @@ # along with IfcPatch. If not, see . import math +import numpy as np +import ifcopenshell +import ifcopenshell.util.placement class Patcher: def __init__(self, src, file, logger, args=None): @@ -36,49 +39,17 @@ class Patcher: absolute_placements.append(absolute_placement) absolute_placements = set(absolute_placements) + angle = float(self.args[3]) + transformation = self.z_rotation_matrix(math.radians(angle)) if angle else np.eye(4) + transformation[0][3] += float(self.args[0]) + transformation[1][3] += float(self.args[1]) + transformation[2][3] += float(self.args[2]) + for placement in absolute_placements: - offset_location = ( - placement.RelativePlacement.Location.Coordinates[0] + float(self.args[0]), - placement.RelativePlacement.Location.Coordinates[1] + float(self.args[1]), - placement.RelativePlacement.Location.Coordinates[2] + float(self.args[2]) + placement.RelativePlacement = self.get_relative_placement( + transformation @ ifcopenshell.util.placement.get_local_placement(placement) ) - relative_placement = self.file.createIfcAxis2Placement3D( - self.file.createIfcCartesianPoint(offset_location)) - - if placement.RelativePlacement.Axis: - relative_placement.Axis = placement.RelativePlacement.Axis - if placement.RelativePlacement.RefDirection: - relative_placement.RefDirection = placement.RelativePlacement.RefDirection - - angle = float(self.args[3]) - if not angle: - placement.RelativePlacement = relative_placement - continue - - rotation_matrix = self.z_rotation_matrix(math.radians(angle)) - - if len(self.args) == 5: - # Move then rotate, if you want - relative_placement.Location.Coordinates = self.multiply_by_matrix(offset_location, rotation_matrix) - else: - # Rotate then move, like Solibri - pass - - if placement.RelativePlacement.Axis: - z_axis = placement.RelativePlacement.Axis.DirectionRatios - relative_placement.Axis = self.file.createIfcDirection( - self.multiply_by_matrix(z_axis, rotation_matrix)) - - if placement.RelativePlacement.RefDirection: - x_axis = placement.RelativePlacement.RefDirection.DirectionRatios - else: - x_axis = (1., 0., 0.) - relative_placement.RefDirection = self.file.createIfcDirection( - self.multiply_by_matrix(x_axis, rotation_matrix)) - - placement.RelativePlacement = relative_placement - def get_absolute_placement(self, object_placement): if object_placement.PlacementRelTo: return self.get_absolute_placement(object_placement.PlacementRelTo) @@ -86,14 +57,26 @@ class Patcher: def z_rotation_matrix(self, angle): return [ - [math.cos(angle), -math.sin(angle), 0.], - [math.sin(angle), math.cos(angle), 0.], - [0., 0., 1.] + [math.cos(angle), -math.sin(angle), 0., 0.], + [math.sin(angle), math.cos(angle), 0., 0.], + [0., 0., 1., 0.], + [0., 0., 0., 1.], ] - def multiply_by_matrix(self, v, m): - return [ - v[0]*m[0][0] + v[1]*m[0][1] + v[2]*m[0][2], - v[0]*m[1][0] + v[1]*m[1][1] + v[2]*m[1][2], - v[0]*m[2][0] + v[1]*m[2][1] + v[2]*m[2][2] - ] + def get_relative_placement(self, m): + x = np.array((m[0][0], m[1][0], m[2][0])) + z = np.array((m[0][2], m[1][2], m[2][2])) + o = np.array((m[0][3], m[1][3], m[2][3])) + object_matrix = ifcopenshell.util.placement.a2p(o, z, x) + return self.create_ifc_axis_2_placement_3d( + object_matrix[:, 3][0:3], + object_matrix[:, 2][0:3], + object_matrix[:, 0][0:3], + ) + + def create_ifc_axis_2_placement_3d(self, point, up, forward): + return self.file.createIfcAxis2Placement3D( + self.file.createIfcCartesianPoint(point.tolist()), + self.file.createIfcDirection(up.tolist()), + self.file.createIfcDirection(forward.tolist()), + ) From 38170db32afb0d1226e09576930b14f8613c06ea Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Tue, 22 Mar 2022 22:30:27 +0100 Subject: [PATCH 52/85] #2080 Don't allow parameterless Kernel constructor --- src/ifcgeom/IfcGeom.h | 4 ++-- src/ifcgeom_schema_agnostic/Kernel.h | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ifcgeom/IfcGeom.h b/src/ifcgeom/IfcGeom.h index cdf3a6414c..687a437c4c 100644 --- a/src/ifcgeom/IfcGeom.h +++ b/src/ifcgeom/IfcGeom.h @@ -299,7 +299,7 @@ private: public: MAKE_TYPE_NAME(Kernel)() - : IfcGeom::Kernel(0) + : IfcGeom::Kernel() , deflection_tolerance(0.001) , max_faces_to_orient(-1.0) , ifc_length_unit(1.0) @@ -321,7 +321,7 @@ public: {} MAKE_TYPE_NAME(Kernel)(const MAKE_TYPE_NAME(Kernel)& other) - : IfcGeom::Kernel(0) + : IfcGeom::Kernel() , deflection_tolerance(other.deflection_tolerance) , max_faces_to_orient(other.max_faces_to_orient) , ifc_length_unit(other.ifc_length_unit) diff --git a/src/ifcgeom_schema_agnostic/Kernel.h b/src/ifcgeom_schema_agnostic/Kernel.h index 3aab1e4632..ceb6174755 100644 --- a/src/ifcgeom_schema_agnostic/Kernel.h +++ b/src/ifcgeom_schema_agnostic/Kernel.h @@ -42,6 +42,9 @@ namespace IfcGeom { private: Kernel* implementation_; + protected: + Kernel() {}; + public: // Tolerances and settings for various geometrical operations: enum GeomValue { @@ -80,7 +83,7 @@ namespace IfcGeom { GV_BOOLEAN_ATTEMPT_2D }; - Kernel(IfcParse::IfcFile* file_ = 0); + Kernel(IfcParse::IfcFile* file_); virtual ~Kernel() {} From 38f1c622ac09cc81e95f4d7eb4360f67af4b18b3 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 23 Mar 2022 11:54:42 +1100 Subject: [PATCH 53/85] Allow case insensitive IFC file extensions when exporting --- src/blenderbim/blenderbim/bim/export_ifc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/export_ifc.py b/src/blenderbim/blenderbim/bim/export_ifc.py index 729786b7a7..063c793c80 100644 --- a/src/blenderbim/blenderbim/bim/export_ifc.py +++ b/src/blenderbim/blenderbim/bim/export_ifc.py @@ -48,7 +48,7 @@ class IfcExporter: self.sync_deletions() self.sync_all_objects() self.sync_edited_objects() - extension = self.ifc_export_settings.output_file.split(".")[-1] + extension = self.ifc_export_settings.output_file.split(".")[-1].lower() if extension == "ifczip": with tempfile.TemporaryDirectory() as unzipped_path: filename, ext = os.path.splitext(os.path.basename(self.ifc_export_settings.output_file)) From c45c73c563b019476322149d28d40c1cd0537c59 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 23 Mar 2022 20:19:19 +1100 Subject: [PATCH 54/85] #2078. Fix bug where flat materials didn't import correctly. --- src/blenderbim/blenderbim/bim/import_ifc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/import_ifc.py b/src/blenderbim/blenderbim/bim/import_ifc.py index 4f9149f974..80b3c2d193 100644 --- a/src/blenderbim/blenderbim/bim/import_ifc.py +++ b/src/blenderbim/blenderbim/bim/import_ifc.py @@ -1356,12 +1356,12 @@ class IfcImporter: elif surface_style.ReflectanceMethod == "FLAT": blender_material.use_nodes = True - output = {n.type: n for n in self.settings["material"].node_tree.nodes}.get("OUTPUT_MATERIAL", None) + output = {n.type: n for n in blender_material.node_tree.nodes}.get("OUTPUT_MATERIAL", None) bsdf = blender_material.node_tree.nodes["Principled BSDF"] mix = blender_material.node_tree.nodes.new(type="ShaderNodeMixShader") mix.location = bsdf.location - blender_material.node_tree.links.new(lightpath.outputs[0], output.inputs["Surface"]) + blender_material.node_tree.links.new(mix.outputs[0], output.inputs["Surface"]) blender_material.node_tree.nodes.remove(bsdf) From 4f86dd143a541f9aafb5768c9b2b7bb3102e4ec4 Mon Sep 17 00:00:00 2001 From: Boris Brangeon Date: Thu, 24 Mar 2022 01:02:58 +0100 Subject: [PATCH 55/85] Update selector.py (#2102) Add BOOLEAN: "TRUE" | "FALSE" | "true" | "false"| "True" | "False" --- src/ifcopenshell-python/ifcopenshell/util/selector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index 419937aac3..8a32c4674d 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -55,7 +55,7 @@ class Selector: equal: "=" morethan: ">" lessthan: "<" - BOOLEAN: "TRUE" | "FALSE" + BOOLEAN: "TRUE" | "FALSE" | "true" | "false"| "True" | "False" NULL: "NULL" // Embed common.lark for packaging From cbc6eefca743702d73f8ef949224f4ef4fd0c6ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 26 Feb 2022 20:56:32 +0000 Subject: [PATCH 56/85] Bump lxml from 4.6.3 to 4.6.5 in /src/ifcopenshell-python/ifcopenshell Bumps [lxml](https://github.com/lxml/lxml) from 4.6.3 to 4.6.5. - [Release notes](https://github.com/lxml/lxml/releases) - [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt) - [Commits](https://github.com/lxml/lxml/compare/lxml-4.6.3...lxml-4.6.5) --- updated-dependencies: - dependency-name: lxml dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- src/ifcopenshell-python/ifcopenshell/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/requirements.txt b/src/ifcopenshell-python/ifcopenshell/requirements.txt index 232aae9415..5c5a6cb50e 100644 --- a/src/ifcopenshell-python/ifcopenshell/requirements.txt +++ b/src/ifcopenshell-python/ifcopenshell/requirements.txt @@ -1,4 +1,4 @@ -lxml==4.6.3 +lxml==4.6.5 numpy==1.20.3 regex==2021.4.4 xmlschema==1.6.4 \ No newline at end of file From 8bd826fb208dfe21484209a928180f761006ddee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Mar 2022 09:02:08 +0000 Subject: [PATCH 57/85] Bump numpy in /src/ifcopenshell-python/ifcopenshell Bumps [numpy](https://github.com/numpy/numpy) from 1.20.3 to 1.21.0. - [Release notes](https://github.com/numpy/numpy/releases) - [Changelog](https://github.com/numpy/numpy/blob/main/doc/HOWTO_RELEASE.rst.txt) - [Commits](https://github.com/numpy/numpy/compare/v1.20.3...v1.21.0) --- updated-dependencies: - dependency-name: numpy dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- src/ifcopenshell-python/ifcopenshell/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/requirements.txt b/src/ifcopenshell-python/ifcopenshell/requirements.txt index 5c5a6cb50e..d0a2328f34 100644 --- a/src/ifcopenshell-python/ifcopenshell/requirements.txt +++ b/src/ifcopenshell-python/ifcopenshell/requirements.txt @@ -1,4 +1,4 @@ lxml==4.6.5 -numpy==1.20.3 +numpy==1.21.0 regex==2021.4.4 xmlschema==1.6.4 \ No newline at end of file From 1dd85f6c48767fd4155f332c1f65eb5aacffa8c1 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 25 Mar 2022 15:44:48 +1100 Subject: [PATCH 58/85] Fix bug where removing a rooted or calendar constrained task would purge all calendar assignments --- .../ifcopenshell/api/sequence/remove_task.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_task.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_task.py index edd271731d..6783b5ade5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_task.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/remove_task.py @@ -44,5 +44,10 @@ class Usecase: elif inverse.RelatedObjects == tuple(self.settings["task"]): self.file.remove(inverse) elif inverse.is_a("IfcRelAssignsToControl"): - self.file.remove(inverse) + if len(inverse.RelatedObjects) == 1: + self.file.remove(inverse) + else: + related_objects = list(inverse.RelatedObjects) + related_objects.remove(self.settings["task"]) + inverse.RelatedObjects = related_objects self.file.remove(self.settings["task"]) From 8afa1a4f7dddee46dac5e559b41b8c795245828e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 25 Mar 2022 15:45:50 +1100 Subject: [PATCH 59/85] Fix bug where counting working days on a task without a calendar would fail --- .../ifcopenshell/util/sequence.py | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/sequence.py b/src/ifcopenshell-python/ifcopenshell/util/sequence.py index e9235faeb8..ab020f5ab4 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/sequence.py +++ b/src/ifcopenshell-python/ifcopenshell/util/sequence.py @@ -40,12 +40,28 @@ def count_working_days(start, finish, calendar): while current_date < finish_date: if calendar and calendar.WorkingTimes and is_working_day(current_date, calendar): result += 1 + elif not calendar: + result += 1 current_date += datetime.timedelta(days=1) return result -def get_finish_date(start, duration, duration_type, calendar): - current_date = datetime.date(start.year, start.month, start.day) +def get_start_or_finish_date(start, duration, duration_type, calendar, date_type="FINISH"): + if not duration.days: + # Typically a milestone will have zero duration, so the start == finish + return start + # We minus 1 because the start day itself is counted as a day + duration = datetime.timedelta(days=duration.days - 1) + if date_type == "START": + duration = -duration + result = offset_date(start, duration, duration_type, calendar) + if date_type == "START": + return datetime.datetime.combine(result, datetime.time(9)) + return datetime.datetime.combine(result, datetime.time(17)) + + +def offset_date(start, duration, duration_type, calendar): + current_date = start abs_duration = abs(duration.days) date_offset = datetime.timedelta(days=1 if duration.days > 0 else -1) while abs_duration > 0: From e7f6ed73acdb2ffc248a4cd5c1a337909556936b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 25 Mar 2022 15:46:19 +1100 Subject: [PATCH 60/85] Fix bug where recalculating a schedule with no time data would fail --- .../ifcopenshell/api/sequence/recalculate_schedule.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py index a55196944f..adbea76e1b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py @@ -36,6 +36,9 @@ class Usecase: self.start_dates = [] self.build_network_graph() + if not self.start_dates: + return + self.pending_nodes = set(self.g.nodes) while self.pending_nodes: remaining_nodes = set() From 7af53c543ddea325e076d9108c16a4ddac8ee119 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 25 Mar 2022 15:49:08 +1100 Subject: [PATCH 61/85] Fix bug with schedule calculation on zero duration tasks --- .../ifcopenshell/api/sequence/recalculate_schedule.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py index adbea76e1b..31e29bcbd8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py @@ -127,13 +127,16 @@ class Usecase: def update_task_times(self): for ifc_definition_id in self.g.nodes: + if ifc_definition_id in ("start", "finish"): + continue data = self.g.nodes[ifc_definition_id] - if not data["duration"]: + task = self.file.by_id(ifc_definition_id) + if not task.TaskTime: continue ifcopenshell.api.run( "sequence.edit_task_time", self.file, - task_time=self.file.by_id(ifc_definition_id).TaskTime, + task_time=task.TaskTime, attributes={ "FreeFloat": ifcopenshell.util.date.datetime2ifc(data["free_float"], "IfcDuration"), "TotalFloat": ifcopenshell.util.date.datetime2ifc(data["total_float"], "IfcDuration"), From 9db07883b7edc65929bb7a89349583fdf99d4cf4 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 25 Mar 2022 15:53:05 +1100 Subject: [PATCH 62/85] Scheduling calculations no longer calculate from midnight and now handle a single 9-5 day as a duration of 1 day, as per industry standard practice. --- .github/workflows/ci.yml | 1 + .../api/sequence/cascade_schedule.py | 77 +++-- .../api/sequence/edit_task_time.py | 27 +- .../api/sequence/recalculate_schedule.py | 175 ++++++++--- .../api/sequence/test_cascade_schedule.py | 100 +++--- .../test/api/sequence/test_edit_task_time.py | 64 ++-- .../api/sequence/test_recalculate_schedule.py | 284 ++++++++++++++++++ 7 files changed, 576 insertions(+), 152 deletions(-) create mode 100644 src/ifcopenshell-python/test/api/sequence/test_recalculate_schedule.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4347d993f..563db5dd14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,7 @@ jobs: sudo /usr/bin/python -m pip install pytest sudo /usr/bin/python -m pip install isodate sudo /usr/bin/python -m pip install lark + sudo /usr/bin/python -m pip install networkx - name: Test run: | diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/cascade_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/cascade_schedule.py index d578cb0c43..99fc616e33 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/cascade_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/cascade_schedule.py @@ -47,15 +47,32 @@ class Usecase: for rel in task.IsSuccessorFrom: predecessor = rel.RelatingProcess + predecessor_duration = ( + ifcopenshell.util.date.ifc2datetime(predecessor.TaskTime.ScheduleDuration) + if predecessor.TaskTime.ScheduleDuration + else datetime.timedelta() + ) if rel.SequenceType == "FINISH_START": finish = self.get_task_time_attribute(predecessor, "ScheduleFinish") if not finish: continue + days = 0 if predecessor_duration.days == 0 else 1 + duration_type = "WORKTIME" if rel.TimeLag: - days = self.get_lag_time_days(rel.TimeLag) + days += self.get_lag_time_days(rel.TimeLag) duration_type = rel.TimeLag.DurationType - starts.append(self.offset_date(finish, days, duration_type, self.get_calendar(task))) - starts.append(self.offset_date(finish, days, duration_type, self.get_calendar(predecessor))) + if days: + starts.append( + datetime.datetime.combine( + self.offset_date(finish, days, duration_type, self.get_calendar(task)), datetime.time(9) + ) + ) + starts.append( + datetime.datetime.combine( + self.offset_date(finish, days, duration_type, self.get_calendar(predecessor)), + datetime.time(9), + ) + ) else: starts.append(finish) elif rel.SequenceType == "START_START": @@ -84,25 +101,31 @@ class Usecase: start = self.get_task_time_attribute(predecessor, "ScheduleStart") if not start: continue + days = -1 + duration_type = "WORKTIME" if rel.TimeLag: - days = self.get_lag_time_days(rel.TimeLag) + days += self.get_lag_time_days(rel.TimeLag) duration_type = rel.TimeLag.DurationType - finishes.append(self.offset_date(start, days, duration_type, self.get_calendar(task))) - finishes.append(self.offset_date(start, days, duration_type, self.get_calendar(predecessor))) + if days or rel.TimeLag: + finishes.append( + datetime.datetime.combine( + self.offset_date(start, days, duration_type, self.get_calendar(task)), datetime.time(17) + ) + ) + finishes.append( + datetime.datetime.combine( + self.offset_date(start, days, duration_type, self.get_calendar(predecessor)), + datetime.time(17), + ) + ) else: finishes.append(start) if starts and finishes: start = max(starts) finish = max(finishes) - potential_finish = datetime.datetime.combine( - ifcopenshell.util.sequence.get_finish_date( - start, - duration, - task.TaskTime.DurationType, - self.get_calendar(task), - ), - datetime.datetime.min.time(), + potential_finish = ifcopenshell.util.sequence.get_start_or_finish_date( + start, duration, task.TaskTime.DurationType, self.get_calendar(task), date_type="FINISH" ) if potential_finish > finish: start_ifc = ifcopenshell.util.date.datetime2ifc(start, "IfcDateTime") @@ -116,11 +139,8 @@ class Usecase: return task.TaskTime.ScheduleFinish = finish_ifc task.TaskTime.ScheduleStart = ifcopenshell.util.date.datetime2ifc( - ifcopenshell.util.sequence.get_finish_date( - finish, - -duration, - task.TaskTime.DurationType, - self.get_calendar(task), + ifcopenshell.util.sequence.get_start_or_finish_date( + finish, duration, task.TaskTime.DurationType, self.get_calendar(task), date_type="START" ), "IfcDateTime", ) @@ -131,11 +151,8 @@ class Usecase: return task.TaskTime.ScheduleFinish = finish_ifc task.TaskTime.ScheduleStart = ifcopenshell.util.date.datetime2ifc( - ifcopenshell.util.sequence.get_finish_date( - finish, - -duration, - task.TaskTime.DurationType, - self.get_calendar(task), + ifcopenshell.util.sequence.get_start_or_finish_date( + finish, duration, task.TaskTime.DurationType, self.get_calendar(task), date_type="START" ), "IfcDateTime", ) @@ -146,11 +163,8 @@ class Usecase: return task.TaskTime.ScheduleStart = start_ifc task.TaskTime.ScheduleFinish = ifcopenshell.util.date.datetime2ifc( - ifcopenshell.util.sequence.get_finish_date( - start, - duration, - task.TaskTime.DurationType, - self.get_calendar(task), + ifcopenshell.util.sequence.get_start_or_finish_date( + start, duration, task.TaskTime.DurationType, self.get_calendar(task), date_type="FINISH" ), "IfcDateTime", ) @@ -167,10 +181,7 @@ class Usecase: return self.calendar_cache[task.id()] def offset_date(self, date, days, duration_type, calendar): - return datetime.datetime.combine( - ifcopenshell.util.sequence.get_finish_date(date, datetime.timedelta(days=days), duration_type, calendar), - datetime.datetime.min.time(), - ) + return ifcopenshell.util.sequence.offset_date(date, datetime.timedelta(days=days), duration_type, calendar) def get_task_time_attribute(self, task, attribute): if task.TaskTime: diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_task_time.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_task_time.py index 6aa19feadf..ff0511e03a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_task_time.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/edit_task_time.py @@ -40,13 +40,21 @@ class Usecase: del self.settings["attributes"]["ScheduleFinish"] duration_type = self.settings["attributes"].get("DurationType", self.settings["task_time"].DurationType) - if self.settings["attributes"].get("ScheduleFinish", None): - self.settings["attributes"]["ScheduleFinish"] = ifcopenshell.util.sequence.get_soonest_working_day( - self.settings["attributes"]["ScheduleFinish"], duration_type, self.calendar + finish = self.settings["attributes"].get("ScheduleFinish", None) + if finish: + if isinstance(finish, str): + finish = datetime.datetime.fromisoformat(finish) + self.settings["attributes"]["ScheduleFinish"] = datetime.datetime.combine( + ifcopenshell.util.sequence.get_soonest_working_day(finish, duration_type, self.calendar), + datetime.time(17), ) - if self.settings["attributes"].get("ScheduleStart", None): - self.settings["attributes"]["ScheduleStart"] = ifcopenshell.util.sequence.get_soonest_working_day( - self.settings["attributes"]["ScheduleStart"], duration_type, self.calendar + start = self.settings["attributes"].get("ScheduleStart", None) + if start: + if isinstance(start, str): + start = datetime.datetime.fromisoformat(start) + self.settings["attributes"]["ScheduleStart"] = datetime.datetime.combine( + ifcopenshell.util.sequence.get_soonest_working_day(start, duration_type, self.calendar), + datetime.time(9), ) for name, value in self.settings["attributes"].items(): @@ -76,20 +84,21 @@ class Usecase: ifcopenshell.api.run("sequence.cascade_schedule", self.file, task=self.task) def calculate_finish(self): - finish_date = ifcopenshell.util.sequence.get_finish_date( + finish = ifcopenshell.util.sequence.get_start_or_finish_date( ifcopenshell.util.date.ifc2datetime(self.settings["task_time"].ScheduleStart), ifcopenshell.util.date.ifc2datetime(self.settings["task_time"].ScheduleDuration), self.settings["task_time"].DurationType, self.calendar, + date_type="FINISH", ) - self.settings["task_time"].ScheduleFinish = ifcopenshell.util.date.datetime2ifc(finish_date, "IfcDateTime") + self.settings["task_time"].ScheduleFinish = ifcopenshell.util.date.datetime2ifc(finish, "IfcDateTime") def calculate_duration(self): start = ifcopenshell.util.date.ifc2datetime(self.settings["task_time"].ScheduleStart) finish = ifcopenshell.util.date.ifc2datetime(self.settings["task_time"].ScheduleFinish) current_date = datetime.date(start.year, start.month, start.day) finish_date = datetime.date(finish.year, finish.month, finish.day) - duration = datetime.timedelta() + duration = datetime.timedelta(days=1) while current_date < finish_date: if self.settings["task_time"].DurationType == "ELAPSEDTIME" or not self.calendar: duration += datetime.timedelta(days=1) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py index 31e29bcbd8..93082a39f8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py @@ -116,14 +116,12 @@ class Usecase: predecessor_types = [rel.SequenceType for rel in task.IsSuccessorFrom] successor_types = [rel.SequenceType for rel in task.IsPredecessorTo] - if not predecessor_types or ( - "FINISH_START" not in predecessor_types and "START_START" not in predecessor_types - ): + if not predecessor_types: self.edges.append(("start", task.id(), {"lag_time": 0, "type": "FS"})) if task.TaskTime and task.TaskTime.ScheduleStart: self.start_dates.append(ifcopenshell.util.date.ifc2datetime(task.TaskTime.ScheduleStart)) - if not successor_types or ("FINISH_START" not in successor_types and "FINISH_FINISH" not in successor_types): - self.edges.append((task.id(), "finish", {"lag_time": 0, "type": "FS"})) + if not successor_types: + self.edges.append((task.id(), "finish", {"lag_time": 0, "type": "FF"})) def update_task_times(self): for ifc_definition_id in self.g.nodes: @@ -149,7 +147,7 @@ class Usecase: ) def offset_date(self, date, days, node): - return ifcopenshell.util.sequence.get_finish_date( + return ifcopenshell.util.sequence.offset_date( date, datetime.timedelta(days=days), node["duration_type"], node["calendar"] ) @@ -170,45 +168,74 @@ class Usecase: finish = predecessor_data.get("early_finish") if finish is None: return - if not edge["lag_time"]: - starts.append(finish) + days = 0 if predecessor_data["duration"] == 0 else 1 + if edge["lag_time"]: + days += edge["lag_time"] + if days: + starts.append(datetime.datetime.combine(self.offset_date(finish, days, data), datetime.time(9))) + starts.append( + datetime.datetime.combine( + self.offset_date(finish, days, predecessor_data), datetime.time(9) + ) + ) else: - starts.append(self.offset_date(finish, edge["lag_time"], data)) - starts.append(self.offset_date(finish, edge["lag_time"], predecessor_data)) + starts.append(finish) elif edge["type"] == "SS": start = predecessor_data.get("early_start") if start is None: return - if not edge["lag_time"]: - starts.append(start) - else: + if edge["lag_time"]: starts.append(self.offset_date(start, edge["lag_time"], data)) starts.append(self.offset_date(start, edge["lag_time"], predecessor_data)) + else: + starts.append(start) elif edge["type"] == "FF": finish = predecessor_data.get("early_finish") if finish is None: return - if not edge["lag_time"]: - finishes.append(finish) - else: + if edge["lag_time"]: finishes.append(self.offset_date(finish, edge["lag_time"], data)) finishes.append(self.offset_date(finish, edge["lag_time"], predecessor_data)) + else: + finishes.append(finish) elif edge["type"] == "SF": start = predecessor_data.get("early_start") if start is None: return - if not edge["lag_time"]: - finishes.append(start) + days = -1 + if edge["lag_time"]: + days += edge["lag_time"] + if days or edge["lag_time"]: + finishes.append( + datetime.datetime.combine(self.offset_date(start, days, data), datetime.time(17)) + ) + finishes.append( + datetime.datetime.combine( + self.offset_date(start, days, predecessor_data), datetime.time(17) + ) + ) else: - finishes.append(self.offset_date(start, edge["lag_time"], data)) - finishes.append(self.offset_date(start, edge["lag_time"], predecessor_data)) + finishes.append(start) if starts and finishes: data["early_start"] = max(starts) data["early_finish"] = max(finishes) - if self.offset_date(data["early_start"], data["duration"], data) > data["early_finish"]: - data["early_finish"] = self.offset_date(data["early_start"], data["duration"], data) + potential_finish = ifcopenshell.util.sequence.get_start_or_finish_date( + data["early_start"], + datetime.timedelta(days=data["duration"]), + data["duration_type"], + data["calendar"], + date_type="FINISH", + ) + if potential_finish > data["early_finish"]: + data["early_finish"] = potential_finish else: - data["early_start"] = self.offset_date(data["early_finish"], -data["duration"], data) + data["early_start"] = ifcopenshell.util.sequence.get_start_or_finish_date( + data["early_finish"], + datetime.timedelta(days=data["duration"]), + data["duration_type"], + data["calendar"], + date_type="START", + ) elif finishes: data["early_finish"] = max(finishes) elif starts: @@ -217,9 +244,21 @@ class Usecase: print("How did this happen?") if data.get("early_finish") is None: - data["early_finish"] = self.offset_date(data["early_start"], data["duration"], data) + data["early_finish"] = ifcopenshell.util.sequence.get_start_or_finish_date( + data["early_start"], + datetime.timedelta(days=data["duration"]), + data["duration_type"], + data["calendar"], + date_type="FINISH", + ) elif data.get("early_start") is None: - data["early_start"] = self.offset_date(data["early_finish"], -data["duration"], data) + data["early_start"] = ifcopenshell.util.sequence.get_start_or_finish_date( + data["early_finish"], + datetime.timedelta(days=data["duration"]), + data["duration_type"], + data["calendar"], + date_type="START", + ) return True @@ -241,25 +280,36 @@ class Usecase: start = successor_data.get("late_start") if start is None: return - if not edge["lag_time"]: - finishes.append(start) + days = 1 + if edge["lag_time"]: + days += edge["lag_time"] + if days or edge["lag_time"]: + finishes.append( + datetime.datetime.combine(self.offset_date(start, -days, data), datetime.time(17)) + ) + finishes.append( + datetime.datetime.combine(self.offset_date(start, -days, successor_data), datetime.time(17)) + ) else: - finishes.append(self.offset_date(start, -edge["lag_time"], data)) - finishes.append(self.offset_date(start, -edge["lag_time"], successor_data)) + finishes.append(start) free_floats.append( self.calculate_free_float( - data["early_finish"], successor_data["early_start"], edge["lag_time"], data, successor_data + data["early_finish"].date() + datetime.timedelta(days=1), + successor_data["early_start"].date(), + edge["lag_time"], + data, + successor_data, ) ) elif edge["type"] == "SS": start = successor_data.get("late_start") if start is None: return - if not edge["lag_time"]: - starts.append(start) - else: + if edge["lag_time"]: starts.append(self.offset_date(start, -edge["lag_time"], data)) starts.append(self.offset_date(start, -edge["lag_time"], successor_data)) + else: + starts.append(start) free_floats.append( self.calculate_free_float( data["early_start"], successor_data["early_start"], edge["lag_time"], data, successor_data @@ -269,11 +319,11 @@ class Usecase: finish = successor_data.get("late_finish") if finish is None: return - if not edge["lag_time"]: - finishes.append(finish) - else: + if edge["lag_time"]: finishes.append(self.offset_date(finish, -edge["lag_time"], data)) finishes.append(self.offset_date(finish, -edge["lag_time"], successor_data)) + else: + finishes.append(finish) free_floats.append( self.calculate_free_float( data["early_finish"], successor_data["early_finish"], edge["lag_time"], data, successor_data @@ -283,11 +333,18 @@ class Usecase: finish = successor_data.get("late_finish") if finish is None: return - if not edge["lag_time"]: - starts.append(finish) + days = 0 if successor_data["duration"] == 0 else -1 + if edge["lag_time"]: + days += edge["lag_time"] + if days: + starts.append( + datetime.datetime.combine(self.offset_date(finish, -days, data), datetime.time(9)) + ) + starts.append( + datetime.datetime.combine(self.offset_date(finish, -days, successor_data), datetime.time(9)) + ) else: - starts.append(self.offset_date(finish, -edge["lag_time"], data)) - starts.append(self.offset_date(finish, -edge["lag_time"], successor_data)) + starts.append(finish) free_floats.append( self.calculate_free_float( data["early_start"], successor_data["early_finish"], edge["lag_time"], data, successor_data @@ -297,9 +354,21 @@ class Usecase: data["late_start"] = min(starts) data["late_finish"] = min(finishes) if self.offset_date(data["late_start"], data["duration"], data) < data["late_finish"]: - data["late_finish"] = self.offset_date(data["late_start"], data["duration"], data) + data["late_finish"] = ifcopenshell.util.sequence.get_start_or_finish_date( + data["late_start"], + datetime.timedelta(days=data["duration"]), + data["duration_type"], + data["calendar"], + date_type="FINISH", + ) else: - data["late_start"] = self.offset_date(data["late_finish"], -data["duration"], data) + data["late_start"] = ifcopenshell.util.sequence.get_start_or_finish_date( + data["late_finish"], + datetime.timedelta(days=data["duration"]), + data["duration_type"], + data["calendar"], + date_type="START", + ) elif finishes: data["late_finish"] = min(finishes) elif starts: @@ -308,9 +377,21 @@ class Usecase: print("How did this happen?") if data.get("late_finish") is None: - data["late_finish"] = self.offset_date(data["late_start"], data["duration"], data) + data["late_finish"] = ifcopenshell.util.sequence.get_start_or_finish_date( + data["late_start"], + datetime.timedelta(days=data["duration"]), + data["duration_type"], + data["calendar"], + date_type="FINISH", + ) elif data.get("late_start") is None: - data["late_start"] = self.offset_date(data["late_finish"], -data["duration"], data) + data["late_start"] = ifcopenshell.util.sequence.get_start_or_finish_date( + data["late_finish"], + datetime.timedelta(days=data["duration"]), + data["duration_type"], + data["calendar"], + date_type="START", + ) if data["duration_type"] == "WORKTIME": data["total_float"] = datetime.timedelta( @@ -320,8 +401,14 @@ class Usecase: ) else: data["total_float"] = data["late_finish"] - data["early_finish"] + # If the float is within the span of a single day, it may show as a 8 hours + if data["total_float"].seconds == 60 * 60 * 8: + data["total_float"] = datetime.timedelta(days=data["total_float"].days + 1) data["free_float"] = min(free_floats) if free_floats else None + # If the float is within the span of a single day, it may show as a 8 hours + if data["free_float"] and data["free_float"].seconds == 60 * 60 * 8: + data["free_float"] = datetime.timedelta(days=data["free_float"].days + 1) return True diff --git a/src/ifcopenshell-python/test/api/sequence/test_cascade_schedule.py b/src/ifcopenshell-python/test/api/sequence/test_cascade_schedule.py index c3811b3758..48e101559b 100644 --- a/src/ifcopenshell-python/test/api/sequence/test_cascade_schedule.py +++ b/src/ifcopenshell-python/test/api/sequence/test_cascade_schedule.py @@ -32,10 +32,10 @@ class TestCascadeSchedule(test.bootstrap.IFC4): task2 = self._create_task("P1D") ifcopenshell.api.run("sequence.cascade_schedule", self.file, task=task) - assert task.TaskTime.ScheduleStart == "2000-01-01T00:00:00" - assert task.TaskTime.ScheduleFinish == "2000-01-02T00:00:00" - assert task2.TaskTime.ScheduleStart == "2000-01-01T00:00:00" - assert task2.TaskTime.ScheduleFinish == "2000-01-02T00:00:00" + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task2.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task2.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" def test_only_cascading_to_successors_not_predecessors(self): task = self._create_task("P1D") @@ -45,13 +45,13 @@ class TestCascadeSchedule(test.bootstrap.IFC4): self._create_sequence(task, task3, "FINISH_START", lag="P1D") ifcopenshell.api.run("sequence.cascade_schedule", self.file, task=task2) - assert task.TaskTime.ScheduleStart == "2000-01-01T00:00:00" - assert task.TaskTime.ScheduleFinish == "2000-01-02T00:00:00" - assert task2.TaskTime.ScheduleStart == "2000-01-02T00:00:00" - assert task2.TaskTime.ScheduleFinish == "2000-01-04T00:00:00" + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task2.TaskTime.ScheduleStart == "2000-01-02T09:00:00" + assert task2.TaskTime.ScheduleFinish == "2000-01-03T17:00:00" # We assert that these start finish times have not cascaded - assert task3.TaskTime.ScheduleStart == "2000-01-02T00:00:00" - assert task3.TaskTime.ScheduleFinish == "2000-01-05T00:00:00" + assert task3.TaskTime.ScheduleStart == "2000-01-02T09:00:00" + assert task3.TaskTime.ScheduleFinish == "2000-01-04T17:00:00" def test_cascading_finish_to_start(self): task = self._create_task("P1D") @@ -61,12 +61,27 @@ class TestCascadeSchedule(test.bootstrap.IFC4): self._create_sequence(task, task3, "FINISH_START", lag="P1D") ifcopenshell.api.run("sequence.cascade_schedule", self.file, task=task) - assert task.TaskTime.ScheduleStart == "2000-01-01T00:00:00" - assert task.TaskTime.ScheduleFinish == "2000-01-02T00:00:00" - assert task2.TaskTime.ScheduleStart == "2000-01-02T00:00:00" - assert task2.TaskTime.ScheduleFinish == "2000-01-04T00:00:00" - assert task3.TaskTime.ScheduleStart == "2000-01-03T00:00:00" - assert task3.TaskTime.ScheduleFinish == "2000-01-06T00:00:00" + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task2.TaskTime.ScheduleStart == "2000-01-02T09:00:00" + assert task2.TaskTime.ScheduleFinish == "2000-01-03T17:00:00" + assert task3.TaskTime.ScheduleStart == "2000-01-03T09:00:00" + assert task3.TaskTime.ScheduleFinish == "2000-01-05T17:00:00" + + def test_cascading_finish_to_start_for_milestones(self): + task = self._create_task("P0D") + task2 = self._create_task("P2D") + task3 = self._create_task("P3D") + self._create_sequence(task, task2, "FINISH_START") + self._create_sequence(task, task3, "FINISH_START", lag="P1D") + + ifcopenshell.api.run("sequence.cascade_schedule", self.file, task=task) + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T09:00:00" + assert task2.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task2.TaskTime.ScheduleFinish == "2000-01-02T17:00:00" + assert task3.TaskTime.ScheduleStart == "2000-01-02T09:00:00" + assert task3.TaskTime.ScheduleFinish == "2000-01-04T17:00:00" def test_cascading_finish_to_finish(self): task = self._create_task("P1D") @@ -76,12 +91,12 @@ class TestCascadeSchedule(test.bootstrap.IFC4): self._create_sequence(task, task3, "FINISH_FINISH", lag="P1D") ifcopenshell.api.run("sequence.cascade_schedule", self.file, task=task) - assert task.TaskTime.ScheduleStart == "2000-01-01T00:00:00" - assert task.TaskTime.ScheduleFinish == "2000-01-02T00:00:00" - assert task2.TaskTime.ScheduleStart == "1999-12-31T00:00:00" - assert task2.TaskTime.ScheduleFinish == "2000-01-02T00:00:00" - assert task3.TaskTime.ScheduleStart == "1999-12-31T00:00:00" - assert task3.TaskTime.ScheduleFinish == "2000-01-03T00:00:00" + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task2.TaskTime.ScheduleStart == "1999-12-31T09:00:00" + assert task2.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task3.TaskTime.ScheduleStart == "1999-12-31T09:00:00" + assert task3.TaskTime.ScheduleFinish == "2000-01-02T17:00:00" def test_cascading_start_to_start(self): task = self._create_task("P1D") @@ -91,12 +106,12 @@ class TestCascadeSchedule(test.bootstrap.IFC4): self._create_sequence(task, task3, "START_START", lag="P1D") ifcopenshell.api.run("sequence.cascade_schedule", self.file, task=task) - assert task.TaskTime.ScheduleStart == "2000-01-01T00:00:00" - assert task.TaskTime.ScheduleFinish == "2000-01-02T00:00:00" - assert task2.TaskTime.ScheduleStart == "2000-01-01T00:00:00" - assert task2.TaskTime.ScheduleFinish == "2000-01-03T00:00:00" - assert task3.TaskTime.ScheduleStart == "2000-01-02T00:00:00" - assert task3.TaskTime.ScheduleFinish == "2000-01-05T00:00:00" + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task2.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task2.TaskTime.ScheduleFinish == "2000-01-02T17:00:00" + assert task3.TaskTime.ScheduleStart == "2000-01-02T09:00:00" + assert task3.TaskTime.ScheduleFinish == "2000-01-04T17:00:00" def test_cascading_start_to_finish(self): task = self._create_task("P1D") @@ -106,15 +121,32 @@ class TestCascadeSchedule(test.bootstrap.IFC4): self._create_sequence(task, task3, "START_FINISH", lag="P1D") ifcopenshell.api.run("sequence.cascade_schedule", self.file, task=task) - assert task.TaskTime.ScheduleStart == "2000-01-01T00:00:00" - assert task.TaskTime.ScheduleFinish == "2000-01-02T00:00:00" - assert task2.TaskTime.ScheduleStart == "1999-12-30T00:00:00" - assert task2.TaskTime.ScheduleFinish == "2000-01-01T00:00:00" - assert task3.TaskTime.ScheduleStart == "1999-12-30T00:00:00" - assert task3.TaskTime.ScheduleFinish == "2000-01-02T00:00:00" + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task2.TaskTime.ScheduleStart == "1999-12-30T09:00:00" + assert task2.TaskTime.ScheduleFinish == "1999-12-31T17:00:00" + assert task3.TaskTime.ScheduleStart == "1999-12-30T09:00:00" + assert task3.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + + def test_cascading_start_to_finish_for_milestones(self): + task = self._create_task("P0D") + task2 = self._create_task("P2D") + task3 = self._create_task("P3D") + self._create_sequence(task, task2, "START_FINISH") + self._create_sequence(task, task3, "START_FINISH", lag="P1D") + + ifcopenshell.api.run("sequence.cascade_schedule", self.file, task=task) + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T09:00:00" + assert task2.TaskTime.ScheduleStart == "1999-12-30T09:00:00" + assert task2.TaskTime.ScheduleFinish == "1999-12-31T17:00:00" + assert task3.TaskTime.ScheduleStart == "1999-12-30T09:00:00" + assert task3.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" def _create_task(self, duration): task = ifcopenshell.api.run("sequence.add_task", self.file) + if duration == "P0D": + task.IsMilestone = True task_time = ifcopenshell.api.run("sequence.add_task_time", self.file, task=task) ifcopenshell.api.run( "sequence.edit_task_time", diff --git a/src/ifcopenshell-python/test/api/sequence/test_edit_task_time.py b/src/ifcopenshell-python/test/api/sequence/test_edit_task_time.py index 4055b0194d..552bbce520 100644 --- a/src/ifcopenshell-python/test/api/sequence/test_edit_task_time.py +++ b/src/ifcopenshell-python/test/api/sequence/test_edit_task_time.py @@ -34,19 +34,19 @@ class TestEditTaskTime(test.bootstrap.IFC4): "UserDefinedDataOrigin": "UserDefinedDataOrigin", "DurationType": "ELAPSEDTIME", "ScheduleDuration": "P1D", - "ScheduleStart": "2000-01-01T00:00:00", - "ScheduleFinish": "2000-01-02T00:00:00", + "ScheduleStart": "2000-01-01T09:00:00", + "ScheduleFinish": "2000-01-01T17:00:00", "EarlyStart": "2000-01-01T00:00:00", - "EarlyFinish": "2000-01-02T00:00:00", + "EarlyFinish": "2000-01-01T00:00:00", "LateStart": "2000-01-01T00:00:00", - "LateFinish": "2000-01-02T00:00:00", + "LateFinish": "2000-01-01T00:00:00", "FreeFloat": "P0D", "TotalFloat": "P0D", "IsCritical": True, "StatusTime": "2000-01-01T00:00:00", "ActualDuration": "P1D", - "ActualStart": "2000-01-01T00:00:00", - "ActualFinish": "2000-01-02T00:00:00", + "ActualStart": "2000-01-01T09:00:00", + "ActualFinish": "2000-01-01T17:00:00", "RemainingTime": "P1D", "Completion": 0.5, }, @@ -56,19 +56,19 @@ class TestEditTaskTime(test.bootstrap.IFC4): assert task_time.UserDefinedDataOrigin == "UserDefinedDataOrigin" assert task_time.DurationType == "ELAPSEDTIME" assert task_time.ScheduleDuration == "P1D" - assert task_time.ScheduleStart == "2000-01-01T00:00:00" - assert task_time.ScheduleFinish == "2000-01-02T00:00:00" + assert task_time.ScheduleStart == "2000-01-01T09:00:00" + assert task_time.ScheduleFinish == "2000-01-01T17:00:00" assert task_time.EarlyStart == "2000-01-01T00:00:00" - assert task_time.EarlyFinish == "2000-01-02T00:00:00" + assert task_time.EarlyFinish == "2000-01-01T00:00:00" assert task_time.LateStart == "2000-01-01T00:00:00" - assert task_time.LateFinish == "2000-01-02T00:00:00" + assert task_time.LateFinish == "2000-01-01T00:00:00" assert task_time.FreeFloat == "P0D" assert task_time.TotalFloat == "P0D" assert task_time.IsCritical == True assert task_time.StatusTime == "2000-01-01T00:00:00" assert task_time.ActualDuration == "P1D" - assert task_time.ActualStart == "2000-01-01T00:00:00" - assert task_time.ActualFinish == "2000-01-02T00:00:00" + assert task_time.ActualStart == "2000-01-01T09:00:00" + assert task_time.ActualFinish == "2000-01-01T17:00:00" assert task_time.RemainingTime == "P1D" assert task_time.Completion == 0.5 @@ -80,11 +80,11 @@ class TestEditTaskTime(test.bootstrap.IFC4): task_time=task_time, attributes={ "ScheduleDuration": None, - "ScheduleStart": "2000-01-01T00:00:00", + "ScheduleStart": "2000-01-01T09:00:00", "ScheduleFinish": None, }, ) - assert task_time.ScheduleStart == "2000-01-01T00:00:00" + assert task_time.ScheduleStart == "2000-01-01T09:00:00" assert task_time.ScheduleFinish is None assert task_time.ScheduleDuration is None @@ -104,7 +104,7 @@ class TestEditTaskTime(test.bootstrap.IFC4): "ScheduleFinish": None, }, ) - assert task_time.ScheduleStart == "2000-01-01T00:00:00" + assert task_time.ScheduleStart == "2000-01-01T09:00:00" assert task_time.ScheduleFinish is None assert task_time.ScheduleDuration is None @@ -117,13 +117,13 @@ class TestEditTaskTime(test.bootstrap.IFC4): attributes={ "DurationType": "ELAPSEDTIME", "ScheduleDuration": "P1D", - "ScheduleStart": "2000-01-01T00:00:00", + "ScheduleStart": "2000-01-01T09:00:00", }, ) assert task_time.DurationType == "ELAPSEDTIME" assert task_time.ScheduleDuration == "P1D" - assert task_time.ScheduleStart == "2000-01-01T00:00:00" - assert task_time.ScheduleFinish == "2000-01-02T00:00:00" + assert task_time.ScheduleStart == "2000-01-01T09:00:00" + assert task_time.ScheduleFinish == "2000-01-01T17:00:00" def test_schedule_durations_are_auto_calculated_if_possible(self): task_time = ifcopenshell.api.run("sequence.add_task_time", self.file, task=self.file.createIfcTask()) @@ -133,14 +133,14 @@ class TestEditTaskTime(test.bootstrap.IFC4): task_time=task_time, attributes={ "DurationType": "ELAPSEDTIME", - "ScheduleStart": "2000-01-01T00:00:00", - "ScheduleFinish": "2000-01-02T00:00:00", + "ScheduleStart": "2000-01-01T09:00:00", + "ScheduleFinish": "2000-01-01T17:00:00", }, ) assert task_time.DurationType == "ELAPSEDTIME" assert task_time.ScheduleDuration == "P1D" - assert task_time.ScheduleStart == "2000-01-01T00:00:00" - assert task_time.ScheduleFinish == "2000-01-02T00:00:00" + assert task_time.ScheduleStart == "2000-01-01T09:00:00" + assert task_time.ScheduleFinish == "2000-01-01T17:00:00" def test_a_duration_takes_priority_over_start_and_finish_dates(self): task_time = ifcopenshell.api.run("sequence.add_task_time", self.file, task=self.file.createIfcTask()) @@ -151,14 +151,14 @@ class TestEditTaskTime(test.bootstrap.IFC4): attributes={ "DurationType": "ELAPSEDTIME", "ScheduleDuration": "P1D", - "ScheduleStart": "2000-01-01T00:00:00", - "ScheduleFinish": "2000-01-03T00:00:00", + "ScheduleStart": "2000-01-01T09:00:00", + "ScheduleFinish": "2000-01-03T17:00:00", }, ) assert task_time.DurationType == "ELAPSEDTIME" assert task_time.ScheduleDuration == "P1D" - assert task_time.ScheduleStart == "2000-01-01T00:00:00" - assert task_time.ScheduleFinish == "2000-01-02T00:00:00" + assert task_time.ScheduleStart == "2000-01-01T09:00:00" + assert task_time.ScheduleFinish == "2000-01-01T17:00:00" def test_durations_can_be_specified_in_datetime_objects(self): task_time = ifcopenshell.api.run("sequence.add_task_time", self.file, task=self.file.createIfcTask()) @@ -169,13 +169,13 @@ class TestEditTaskTime(test.bootstrap.IFC4): attributes={ "DurationType": "ELAPSEDTIME", "ScheduleDuration": datetime.timedelta(days=1), - "ScheduleStart": "2000-01-01T00:00:00", + "ScheduleStart": "2000-01-01T09:00:00", }, ) assert task_time.DurationType == "ELAPSEDTIME" assert task_time.ScheduleDuration == "P1D" - assert task_time.ScheduleStart == "2000-01-01T00:00:00" - assert task_time.ScheduleFinish == "2000-01-02T00:00:00" + assert task_time.ScheduleStart == "2000-01-01T09:00:00" + assert task_time.ScheduleFinish == "2000-01-01T17:00:00" def test_zero_durations_are_allowed(self): task_time = ifcopenshell.api.run("sequence.add_task_time", self.file, task=self.file.createIfcTask()) @@ -186,10 +186,10 @@ class TestEditTaskTime(test.bootstrap.IFC4): attributes={ "DurationType": "ELAPSEDTIME", "ScheduleDuration": datetime.timedelta(), - "ScheduleStart": "2000-01-01T00:00:00", + "ScheduleStart": "2000-01-01T09:00:00", }, ) assert task_time.DurationType == "ELAPSEDTIME" assert task_time.ScheduleDuration == "P0D" - assert task_time.ScheduleStart == "2000-01-01T00:00:00" - assert task_time.ScheduleFinish == "2000-01-01T00:00:00" + assert task_time.ScheduleStart == "2000-01-01T09:00:00" + assert task_time.ScheduleFinish == "2000-01-01T09:00:00" diff --git a/src/ifcopenshell-python/test/api/sequence/test_recalculate_schedule.py b/src/ifcopenshell-python/test/api/sequence/test_recalculate_schedule.py new file mode 100644 index 0000000000..399bd132cc --- /dev/null +++ b/src/ifcopenshell-python/test/api/sequence/test_recalculate_schedule.py @@ -0,0 +1,284 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2022 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import datetime +import test.bootstrap +import ifcopenshell.api + + +# A good way for checking these is to recreate them in ProjectLibre +class TestRecalculateSchedule(test.bootstrap.IFC4): + def test_doing_nothing_if_the_task_has_no_time(self): + self._add_work_schedule() + task = ifcopenshell.api.run("sequence.add_task", self.file) + ifcopenshell.api.run("sequence.recalculate_schedule", self.file, work_schedule=self.work_schedule) + assert task.TaskTime is None + + def test_recalculating_for_a_single_task(self): + self._add_work_schedule() + task = self._create_task("P1D") + ifcopenshell.api.run("sequence.recalculate_schedule", self.file, work_schedule=self.work_schedule) + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task.TaskTime.EarlyStart == "2000-01-01T09:00:00" + assert task.TaskTime.EarlyFinish == "2000-01-01T17:00:00" + assert task.TaskTime.LateStart == "2000-01-01T09:00:00" + assert task.TaskTime.LateFinish == "2000-01-01T17:00:00" + assert task.TaskTime.TotalFloat == "P0D" + assert task.TaskTime.FreeFloat == "P0D" + assert task.TaskTime.IsCritical is True + + def test_recalculating_finish_to_start(self): + self._add_work_schedule() + task = self._create_task("P1D") + task2 = self._create_task("P2D") + self._create_sequence(task, task2, "FINISH_START") + ifcopenshell.api.run("sequence.recalculate_schedule", self.file, work_schedule=self.work_schedule) + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task.TaskTime.EarlyStart == "2000-01-01T09:00:00" + assert task.TaskTime.EarlyFinish == "2000-01-01T17:00:00" + assert task.TaskTime.LateStart == "2000-01-01T09:00:00" + assert task.TaskTime.LateFinish == "2000-01-01T17:00:00" + assert task.TaskTime.TotalFloat == "P0D" + assert task.TaskTime.FreeFloat == "P0D" + assert task.TaskTime.IsCritical is True + assert task2.TaskTime.ScheduleStart == "2000-01-02T09:00:00" + assert task2.TaskTime.ScheduleFinish == "2000-01-03T17:00:00" + assert task2.TaskTime.EarlyStart == "2000-01-02T09:00:00" + assert task2.TaskTime.EarlyFinish == "2000-01-03T17:00:00" + assert task2.TaskTime.LateStart == "2000-01-02T09:00:00" + assert task2.TaskTime.LateFinish == "2000-01-03T17:00:00" + assert task2.TaskTime.TotalFloat == "P0D" + assert task2.TaskTime.FreeFloat == "P0D" + assert task2.TaskTime.IsCritical is True + + def test_recalculating_multiple_finish_to_start(self): + self._add_work_schedule() + task = self._create_task("P1D") + task2 = self._create_task("P2D") + task3 = self._create_task("P3D") + self._create_sequence(task, task2, "FINISH_START") + self._create_sequence(task, task3, "FINISH_START", lag="P1D") + ifcopenshell.api.run("sequence.cascade_schedule", self.file, task=task) + ifcopenshell.api.run("sequence.recalculate_schedule", self.file, work_schedule=self.work_schedule) + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task.TaskTime.EarlyStart == "2000-01-01T09:00:00" + assert task.TaskTime.EarlyFinish == "2000-01-01T17:00:00" + assert task.TaskTime.LateStart == "2000-01-01T09:00:00" + assert task.TaskTime.LateFinish == "2000-01-01T17:00:00" + assert task.TaskTime.TotalFloat == "P0D" + assert task.TaskTime.FreeFloat == "P0D" + assert task.TaskTime.IsCritical is True + assert task2.TaskTime.ScheduleStart == "2000-01-02T09:00:00" + assert task2.TaskTime.ScheduleFinish == "2000-01-03T17:00:00" + assert task2.TaskTime.EarlyStart == "2000-01-02T09:00:00" + assert task2.TaskTime.EarlyFinish == "2000-01-03T17:00:00" + assert task2.TaskTime.LateStart == "2000-01-04T09:00:00" + assert task2.TaskTime.LateFinish == "2000-01-05T17:00:00" + assert task2.TaskTime.TotalFloat == "P2D" + assert task2.TaskTime.FreeFloat == "P2D" + assert task2.TaskTime.IsCritical is False + assert task3.TaskTime.ScheduleStart == "2000-01-03T09:00:00" + assert task3.TaskTime.ScheduleFinish == "2000-01-05T17:00:00" + assert task3.TaskTime.EarlyStart == "2000-01-03T09:00:00" + assert task3.TaskTime.EarlyFinish == "2000-01-05T17:00:00" + assert task3.TaskTime.LateStart == "2000-01-03T09:00:00" + assert task3.TaskTime.LateFinish == "2000-01-05T17:00:00" + assert task3.TaskTime.TotalFloat == "P0D" + assert task3.TaskTime.FreeFloat == "P0D" + assert task3.TaskTime.IsCritical is True + + def test_recalculating_finish_to_start_with_a_milestone(self): + self._add_work_schedule() + task = self._create_task("P1D") + task2 = self._create_task("P2D") + task3 = self._create_task("P0D") + self._create_sequence(task, task2, "FINISH_START") + self._create_sequence(task, task3, "FINISH_START", lag="P1D") + ifcopenshell.api.run("sequence.cascade_schedule", self.file, task=task) + ifcopenshell.api.run("sequence.recalculate_schedule", self.file, work_schedule=self.work_schedule) + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task.TaskTime.EarlyStart == "2000-01-01T09:00:00" + assert task.TaskTime.EarlyFinish == "2000-01-01T17:00:00" + assert task.TaskTime.LateStart == "2000-01-01T09:00:00" + assert task.TaskTime.LateFinish == "2000-01-01T17:00:00" + assert task.TaskTime.TotalFloat == "P0D" + assert task.TaskTime.FreeFloat == "P0D" + assert task.TaskTime.IsCritical is True + assert task2.TaskTime.ScheduleStart == "2000-01-02T09:00:00" + assert task2.TaskTime.ScheduleFinish == "2000-01-03T17:00:00" + assert task2.TaskTime.EarlyStart == "2000-01-02T09:00:00" + assert task2.TaskTime.EarlyFinish == "2000-01-03T17:00:00" + assert task2.TaskTime.LateStart == "2000-01-02T09:00:00" + assert task2.TaskTime.LateFinish == "2000-01-03T17:00:00" + assert task2.TaskTime.TotalFloat == "P0D" + assert task2.TaskTime.FreeFloat == "P0D" + assert task2.TaskTime.IsCritical is True + assert task3.TaskTime.ScheduleStart == "2000-01-03T09:00:00" + assert task3.TaskTime.ScheduleFinish == "2000-01-03T09:00:00" + assert task3.TaskTime.EarlyStart == "2000-01-03T09:00:00" + assert task3.TaskTime.EarlyFinish == "2000-01-03T09:00:00" + assert task3.TaskTime.LateStart == "2000-01-03T17:00:00" + assert task3.TaskTime.LateFinish == "2000-01-03T17:00:00" + assert task3.TaskTime.TotalFloat == "P1D" + assert task3.TaskTime.FreeFloat == "P1D" + assert task3.TaskTime.IsCritical is False + + def test_recalculating_finish_to_start_with_a_milestone_as_the_last_task(self): + self._add_work_schedule() + task = self._create_task("P1D") + task2 = self._create_task("P2D") + task3 = self._create_task("P0D") + self._create_sequence(task, task2, "FINISH_START") + self._create_sequence(task2, task3, "FINISH_START") + ifcopenshell.api.run("sequence.cascade_schedule", self.file, task=task) + ifcopenshell.api.run("sequence.recalculate_schedule", self.file, work_schedule=self.work_schedule) + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task.TaskTime.EarlyStart == "2000-01-01T09:00:00" + assert task.TaskTime.EarlyFinish == "2000-01-01T17:00:00" + assert task.TaskTime.LateStart == "2000-01-01T09:00:00" + assert task.TaskTime.LateFinish == "2000-01-01T17:00:00" + assert task.TaskTime.TotalFloat == "P0D" + assert task.TaskTime.FreeFloat == "P0D" + assert task.TaskTime.IsCritical is True + assert task2.TaskTime.ScheduleStart == "2000-01-02T09:00:00" + assert task2.TaskTime.ScheduleFinish == "2000-01-03T17:00:00" + assert task2.TaskTime.EarlyStart == "2000-01-02T09:00:00" + assert task2.TaskTime.EarlyFinish == "2000-01-03T17:00:00" + assert task2.TaskTime.LateStart == "2000-01-02T09:00:00" + assert task2.TaskTime.LateFinish == "2000-01-03T17:00:00" + assert task2.TaskTime.TotalFloat == "P0D" + assert task2.TaskTime.FreeFloat == "P0D" + assert task2.TaskTime.IsCritical is True + assert task3.TaskTime.ScheduleStart == "2000-01-04T09:00:00" + assert task3.TaskTime.ScheduleFinish == "2000-01-04T09:00:00" + assert task3.TaskTime.EarlyStart == "2000-01-04T09:00:00" + assert task3.TaskTime.EarlyFinish == "2000-01-04T09:00:00" + assert task3.TaskTime.LateStart == "2000-01-04T09:00:00" + assert task3.TaskTime.LateFinish == "2000-01-04T09:00:00" + assert task3.TaskTime.TotalFloat == "P0D" + assert task3.TaskTime.FreeFloat == "P0D" + assert task3.TaskTime.IsCritical is True + + def test_recalculating_finish_to_finish(self): + self._add_work_schedule() + task = self._create_task("P1D") + task2 = self._create_task("P2D") + self._create_sequence(task, task2, "FINISH_FINISH") + ifcopenshell.api.run("sequence.recalculate_schedule", self.file, work_schedule=self.work_schedule) + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task.TaskTime.EarlyStart == "2000-01-01T09:00:00" + assert task.TaskTime.EarlyFinish == "2000-01-01T17:00:00" + assert task.TaskTime.LateStart == "2000-01-01T09:00:00" + assert task.TaskTime.LateFinish == "2000-01-01T17:00:00" + assert task.TaskTime.TotalFloat == "P0D" + assert task.TaskTime.FreeFloat == "P0D" + assert task.TaskTime.IsCritical is True + assert task2.TaskTime.ScheduleStart == "1999-12-31T09:00:00" + assert task2.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task2.TaskTime.EarlyStart == "1999-12-31T09:00:00" + assert task2.TaskTime.EarlyFinish == "2000-01-01T17:00:00" + assert task2.TaskTime.LateStart == "1999-12-31T09:00:00" + assert task2.TaskTime.LateFinish == "2000-01-01T17:00:00" + assert task2.TaskTime.TotalFloat == "P0D" + assert task2.TaskTime.FreeFloat == "P0D" + assert task2.TaskTime.IsCritical is True + + def test_recalculating_start_to_start(self): + self._add_work_schedule() + task = self._create_task("P1D") + task2 = self._create_task("P2D") + self._create_sequence(task, task2, "START_START") + ifcopenshell.api.run("sequence.recalculate_schedule", self.file, work_schedule=self.work_schedule) + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task.TaskTime.EarlyStart == "2000-01-01T09:00:00" + assert task.TaskTime.EarlyFinish == "2000-01-01T17:00:00" + assert task.TaskTime.LateStart == "2000-01-01T09:00:00" + assert task.TaskTime.LateFinish == "2000-01-01T17:00:00" + assert task.TaskTime.TotalFloat == "P0D" + assert task.TaskTime.FreeFloat == "P0D" + assert task.TaskTime.IsCritical is True + assert task2.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task2.TaskTime.ScheduleFinish == "2000-01-02T17:00:00" + assert task2.TaskTime.EarlyStart == "2000-01-01T09:00:00" + assert task2.TaskTime.EarlyFinish == "2000-01-02T17:00:00" + assert task2.TaskTime.LateStart == "2000-01-01T09:00:00" + assert task2.TaskTime.LateFinish == "2000-01-02T17:00:00" + assert task2.TaskTime.TotalFloat == "P0D" + assert task2.TaskTime.FreeFloat == "P0D" + assert task2.TaskTime.IsCritical is True + + def test_recalculating_start_to_finish(self): + self._add_work_schedule() + task = self._create_task("P1D") + task2 = self._create_task("P2D") + self._create_sequence(task, task2, "START_FINISH") + ifcopenshell.api.run("sequence.recalculate_schedule", self.file, work_schedule=self.work_schedule) + assert task.TaskTime.ScheduleStart == "2000-01-01T09:00:00" + assert task.TaskTime.ScheduleFinish == "2000-01-01T17:00:00" + assert task.TaskTime.EarlyStart == "2000-01-01T09:00:00" + assert task.TaskTime.EarlyFinish == "2000-01-01T17:00:00" + assert task.TaskTime.LateStart == "2000-01-01T09:00:00" + assert task.TaskTime.LateFinish == "2000-01-01T17:00:00" + assert task.TaskTime.TotalFloat == "P0D" + assert task.TaskTime.FreeFloat == "P0D" + assert task.TaskTime.IsCritical is True + assert task2.TaskTime.ScheduleStart == "1999-12-30T09:00:00" + assert task2.TaskTime.ScheduleFinish == "1999-12-31T17:00:00" + assert task2.TaskTime.EarlyStart == "1999-12-30T09:00:00" + assert task2.TaskTime.EarlyFinish == "1999-12-31T17:00:00" + assert task2.TaskTime.LateStart == "1999-12-30T09:00:00" + assert task2.TaskTime.LateFinish == "1999-12-31T17:00:00" + assert task2.TaskTime.TotalFloat == "P0D" + assert task2.TaskTime.FreeFloat == "P0D" + assert task2.TaskTime.IsCritical is True + + def _add_work_schedule(self): + ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcProject") + self.work_schedule = ifcopenshell.api.run("sequence.add_work_schedule", self.file) + + def _create_task(self, duration): + task = ifcopenshell.api.run("sequence.add_task", self.file, work_schedule=self.work_schedule) + if duration == "P0D": + task.IsMilestone = True + task_time = ifcopenshell.api.run("sequence.add_task_time", self.file, task=task) + ifcopenshell.api.run( + "sequence.edit_task_time", + self.file, + task_time=task_time, + attributes={"ScheduleStart": datetime.date(2000, 1, 1), "ScheduleDuration": duration}, + ) + return task + + def _create_sequence(self, predecessor, successor, relationship, lag=None): + rel = ifcopenshell.api.run( + "sequence.assign_sequence", self.file, relating_process=predecessor, related_process=successor + ) + ifcopenshell.api.run( + "sequence.edit_sequence", self.file, rel_sequence=rel, attributes={"SequenceType": relationship} + ) + if lag: + ifcopenshell.api.run( + "sequence.assign_lag_time", self.file, rel_sequence=rel, lag_value=lag, duration_type="WORKTIME" + ) From 48d476c2b307a53469657dd6cd9d907dfb2fbabd Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 25 Mar 2022 16:09:15 +1100 Subject: [PATCH 63/85] Gantt charts now show ISO durations and task captions --- src/blenderbim/blenderbim/bim/data/gantt/index.mustache | 4 +++- src/blenderbim/blenderbim/bim/module/sequence/operator.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/data/gantt/index.mustache b/src/blenderbim/blenderbim/bim/data/gantt/index.mustache index 67c5179f05..e75c171e3b 100644 --- a/src/blenderbim/blenderbim/bim/data/gantt/index.mustache +++ b/src/blenderbim/blenderbim/bim/data/gantt/index.mustache @@ -38,7 +38,7 @@