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):