From 00ec587296beb3aa296a94a92a5a411f22f5dc01 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Thu, 2 Jul 2026 12:25:32 +0200 Subject: [PATCH] Bonsai: persist link IFC query across reload Store the selector query used at Link IFC time on the Link PropertyGroup, restore it from the sidecar cache JSON on host IFC reopen, and forward it through LoadLink and ReloadLink so subsequent reloads replay the original filter instead of loading every element. ReloadLink now opens a small dialog pre-populated with the current query, allowing the user to edit it in place without unlink-and-relink. Also swap TestCalculateLinkMatrix off NamedTemporaryFile(delete=True) which held an exclusive Windows handle and blocked the code-under-test from reopening the sidecar path. Closes #8219 Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/project/operator.py | 26 +++++- src/bonsai/bonsai/bim/module/project/prop.py | 6 ++ src/bonsai/bonsai/tool/project.py | 8 ++ src/bonsai/test/tool/test_project.py | 93 +++++++++++++++---- 4 files changed, 116 insertions(+), 17 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index dbe799b857..80ec1e919e 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1422,6 +1422,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): new.ifc_definition_id = reference.id() new.name = filepath new.filepath = filepath + new.query = self.query bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query) @@ -1492,6 +1493,10 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): self.link = tool.Project.get_project_props().links[self.link_index] + # Fall back to the Link's stored query so callers that omit it + # still replay the filter the link was created with. + if not self.query and self.link.query: + self.query = self.link.query filepath = Path(tool.Ifc.resolve_uri(self.link.filepath)) if not filepath.exists(): self.report({"ERROR"}, f"File does not exist: '{filepath}'") @@ -1659,13 +1664,32 @@ class ReloadLink(bpy.types.Operator): bl_description = "Reload the selected file" link_index: bpy.props.IntProperty(name="Link Index") + query: bpy.props.StringProperty( + name="Query", + description=( + "Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n" + "Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement." + ), + ) if TYPE_CHECKING: link_index: int + query: str + + def invoke(self, context, event): + link = tool.Project.get_project_props().links[self.link_index] + self.query = link.query + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + assert self.layout + self.layout.prop(self, "query", placeholder="IfcElement") def execute(self, context): + link = tool.Project.get_project_props().links[self.link_index] + link.query = self.query bpy.ops.bim.unload_link(link_index=self.link_index) - return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"} + return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False, query=self.query) or {"FINISHED"} class ToggleLinkSelectability(bpy.types.Operator): diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index d7a11a5e05..00a6d70d29 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -260,6 +260,11 @@ class Link(PropertyGroup): description="STEP ID of the IfcDocumentReference when linked to a parent IFC project. Zero when no parent IFC exists", default=0, ) + query: StringProperty( + name="Query", + description="Selector query used to filter elements when loading the linked model", + default="", + ) if TYPE_CHECKING: name: str @@ -275,6 +280,7 @@ class Link(PropertyGroup): include_in_drawings: bool empty_handle: Union[bpy.types.Object, None] ifc_definition_id: int + query: str class EditedObj(PropertyGroup): diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 9edfd5821d..7199c125cf 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -334,6 +334,14 @@ class Project(bonsai.core.tool.Project): if reference[1]: m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4) link.has_transformation = not np.allclose(m, np.eye(4)) + # The selector query used at link time is persisted only in the + # sidecar cache JSON; restore it so Reload/Load replay the filter. + json_filepath = Path(tool.Ifc.resolve_uri(filepath)).with_suffix(".ifc.cache.json") + if json_filepath.exists(): + try: + link.query = json.loads(json_filepath.read_text()).get("query", "") + except (OSError, json.JSONDecodeError): + pass @classmethod def get_project_library_elements( diff --git a/src/bonsai/test/tool/test_project.py b/src/bonsai/test/tool/test_project.py index e9e11f4656..2520ab12d0 100644 --- a/src/bonsai/test/tool/test_project.py +++ b/src/bonsai/test/tool/test_project.py @@ -294,64 +294,125 @@ class TestLoadLinkedModels(NewFile): assert props.links[1].ifc_definition_id == reference2.id() assert props.links[1].has_transformation is True + def test_load_linked_models_restores_query_from_cache_json(self): + """The selector query used at link time is persisted only in the + sidecar cache JSON. Reopening the host IFC must restore it onto the + Link PropertyGroup so subsequent Reload/Load replay the same filter.""" + ifc = ifcopenshell.file() + props = tool.Project.get_project_props() + ifcopenshell.api.root.create_entity(ifc, "IfcProject") + document = ifcopenshell.api.document.add_information(ifc) + document.Scope = "LINKED_MODEL" + with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=False) as tmp: + json.dump({"query": "IfcElement, ! IfcOpeningElement"}, tmp) + json_path = Path(tmp.name) + try: + ifc_filepath = tmp.name.replace(".ifc.cache.json", ".ifc") + reference = ifcopenshell.api.document.add_reference(ifc, document) + reference.Location = Path(ifc_filepath).as_posix() + reference.Identification = "" + tool.Ifc.set(ifc) + subject.load_linked_models_from_ifc() + assert len(props.links) == 1 + assert props.links[0].query == "IfcElement, ! IfcOpeningElement" + finally: + json_path.unlink(missing_ok=True) + + def test_load_linked_models_query_defaults_empty_without_cache_json(self): + """When no sidecar cache JSON exists, the restored Link's query field + must default to the empty string. Empty query is the documented signal + for the load path to apply no selector filter.""" + ifc = ifcopenshell.file() + props = tool.Project.get_project_props() + ifcopenshell.api.root.create_entity(ifc, "IfcProject") + document = ifcopenshell.api.document.add_information(ifc) + document.Scope = "LINKED_MODEL" + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = Path(tmpdir) / "no-cache.ifc" + reference = ifcopenshell.api.document.add_reference(ifc, document) + reference.Location = ifc_path.as_posix() + reference.Identification = "" + tool.Ifc.set(ifc) + subject.load_linked_models_from_ifc() + assert len(props.links) == 1 + assert props.links[0].query == "" + class TestCalculateLinkMatrix(NewFile): + def _write_cache_json(self, payload: dict) -> Path: + """Write ``payload`` to a fresh sidecar cache JSON path and return it. + + On Windows, ``NamedTemporaryFile(delete=True)`` holds an exclusive + handle for the ``with`` block's duration, so the code-under-test + cannot open the same path — hence the manual write + unlink pattern. + """ + tmp = NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=False) + try: + json.dump(payload, tmp) + finally: + tmp.close() + return Path(tmp.name) + def test_linking_a_model_without_an_offset_to_our_session_with_no_offset(self): props = tool.Project.get_project_props() gprops = tool.Georeference.get_georeference_props() - with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp: + json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "0,0,0"}) + try: link = props.links.add() - link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc") - json.dump({"model_project_north": "0", "model_origin_si": "0,0,0"}, tmp) - tmp.flush() + link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc") gprops.model_project_north = "0" gprops.model_origin_si = "0,0,0" assert np.allclose(subject.calculate_link_matrix(link), np.eye(4)) + finally: + json_path.unlink(missing_ok=True) def test_linking_an_offset_model_to_our_session_with_no_offset(self): props = tool.Project.get_project_props() gprops = tool.Georeference.get_georeference_props() - with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp: + json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "5,0,0"}) + try: link = props.links.add() - link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc") - json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp) - tmp.flush() + link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc") gprops.model_project_north = "0" gprops.model_origin_si = "0,0,0" m = np.eye(4) m[0][3] = 5 assert np.allclose(subject.calculate_link_matrix(link), m) + finally: + json_path.unlink(missing_ok=True) def test_linking_an_offset_model_to_our_session_with_offset(self): props = tool.Project.get_project_props() gprops = tool.Georeference.get_georeference_props() - with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp: + json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "5,0,0"}) + try: link = props.links.add() - link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc") - json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp) - tmp.flush() + link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc") gprops.model_project_north = "0" gprops.model_origin_si = "2,0,0" m = np.eye(4) m[0][3] = 3 assert np.allclose(subject.calculate_link_matrix(link), m) + finally: + json_path.unlink(missing_ok=True) def test_linking_an_offset_model_to_our_session_with_offset_and_transformation(self): props = tool.Project.get_project_props() gprops = tool.Georeference.get_georeference_props() - with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp: + json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "5,0,0"}) + try: link = props.links.add() - link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc") + link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc") transformation = np.eye(4) transformation[0][3] = 4 link.transformation = ",".join(map(str, transformation.reshape(-1))) - json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp) - tmp.flush() gprops.model_project_north = "0" gprops.model_origin_si = "2,0,0" m = np.eye(4) m[0][3] = 7 assert np.allclose(subject.calculate_link_matrix(link), m) + finally: + json_path.unlink(missing_ok=True) class TestLoadingIfcSqlite(NewFile):