From 6d90048acd7578e4eb66d4e6d66afe537dd7cde2 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 2 Jul 2026 22:06:44 -0500 Subject: [PATCH] Bonsai: per-query caches so one IFC can be linked with several queries Linking the same file twice with different queries previously collided on the single shared .ifc.cache.blend: Blender reuses the loaded library per path, so both links displayed whichever query was cached first (and the other after reopening). Cache blend/json filenames now include a hash of the query (tool.Project.get_link_cache_paths), so each query gets its own library. The empty query keeps the legacy names, and the property sqlite stays shared since it always contains the whole file. All cache-path consumers were updated, including the per-link selectability/visibility toggles which would otherwise affect every link of the file at once. Query persistence moves from the shared sidecar JSON to the per-link IfcDocumentReference.Description (IFC4+, written by link_ifc and reload_link), restored on project load with a legacy JSON fallback that only applies when a file has a single link. The appended-element placement now matches links by the queried instance root empty since filepath alone is ambiguous with several links per file. LoadLink and ReloadLink volatile properties are marked SKIP_SAVE: Blender reuses last-used operator properties on the next interactive invocation, which leaked one link's query into another's load (and would corrupt ReloadLink's is_property_set logic the same way). Co-Authored-By: Claude Fable 5 --- .../bonsai/bim/module/project/operator.py | 56 +++++++++++++----- src/bonsai/bonsai/tool/project.py | 59 ++++++++++++++----- 2 files changed, 84 insertions(+), 31 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index c1dc3c462e..7d092bac65 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1424,6 +1424,9 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): reference = ifcopenshell.api.document.add_reference(tool.Ifc.get(), information=document) reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()]) reference.Location = filepath.replace("\\", "/") + # Persist the query per reference (Description is IFC4+ only). + if self.query and hasattr(reference, "Description"): + reference.Description = self.query new.ifc_definition_id = reference.id() new.name = filepath new.filepath = filepath @@ -1487,9 +1490,12 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} bl_description = "Load the selected file" + # SKIP_SAVE: Blender reuses an operator's last-used property values on the + # next interactive invocation, which would leak one link's query/cache + # settings into another link's load. link_index: bpy.props.IntProperty(name="Link Index") - use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) - query: bpy.props.StringProperty() + use_cache: bpy.props.BoolProperty(name="Use Cache", default=True, options={"SKIP_SAVE"}) + query: bpy.props.StringProperty(options={"SKIP_SAVE"}) if TYPE_CHECKING: link_index: int @@ -1538,22 +1544,22 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): self.link.is_loaded = False def link_ifc(self) -> Union[set[str], None]: - blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend") - h5_filepath = self.filepath_.with_suffix(".ifc.cache.h5") - json_filepath = self.filepath_.with_suffix(".ifc.cache.json") + blend_filepath, json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query) def should_clear_cache() -> bool: if not self.use_cache: return True if not blend_filepath.exists(): return False + if not json_filepath.exists(): + return True data = json.loads(json_filepath.read_text()) # Empty 'query' - model loaded without custom query. # Missing 'query' - model was loaded before custom queries were introduced in Bonsai. query = data.get("query", "") return query != self.query - if should_clear_cache(): + if should_clear_cache() and blend_filepath.exists(): os.remove(blend_filepath) if not blend_filepath.exists(): @@ -1630,7 +1636,7 @@ except Exception as e: if len(tool.Project.get_project_props().links) > 1: return # Only the first link sets the origin - json_filepath = self.filepath_.with_suffix(".ifc.cache.json") + json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query)[1] if not json_filepath.exists(): return @@ -1649,8 +1655,7 @@ except Exception as e: if not (crs_name := (ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}).get("Name", "")): self.link.georeferenced = "NONE" return - reference = tool.Ifc.get().by_id(self.link.ifc_definition_id) - json_filepath = Path(reference.Location).with_suffix(".ifc.cache.json") + json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query)[1] if not json_filepath.exists(): self.link.georeferenced = "NONE" return @@ -1668,20 +1673,26 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} bl_description = "Reload the selected file, optionally changing its file path and load options" + # SKIP_SAVE: this operator distinguishes "provided" from "unset" properties + # via is_property_set, so last-used property retention between interactive + # invocations would leak one link's settings into another's reload. link_index: bpy.props.IntProperty(name="Link Index") filepath: bpy.props.StringProperty( name="File Path", description="Path to the linked IFC file", + options={"SKIP_SAVE"}, ) use_relative_path: bpy.props.BoolProperty( name="Use Relative Path", description="Whether to store linked model path relative to the currently opened IFC file.", default=False, + options={"SKIP_SAVE"}, ) use_cache: bpy.props.BoolProperty( name="Use Cache", description="Reuse the cached geometry if it's still valid instead of reprocessing the IFC", default=False, + options={"SKIP_SAVE"}, ) query: bpy.props.StringProperty( name="Query", @@ -1689,6 +1700,7 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator): "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." ), + options={"SKIP_SAVE"}, ) if TYPE_CHECKING: @@ -1763,6 +1775,10 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator): reference.Location = filepath.replace("\\", "/") if document := tool.Document.get_reference_document(reference): document.Name = Path(filepath).name + if tool.Ifc.get() and link.ifc_definition_id: + reference = tool.Ifc.get().by_id(link.ifc_definition_id) + if hasattr(reference, "Description"): + reference.Description = link.query or None bpy.ops.bim.unload_link(link_index=self.link_index) return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=self.use_cache, query=link.query) or { @@ -1825,7 +1841,7 @@ class ToggleLinkSelectability(bpy.types.Operator): props = tool.Project.get_project_props() link = props.links[self.link_index] self.library_filepath = tool.Blender.ensure_blender_path_is_abs( - Path(link.filepath).with_suffix(".ifc.cache.blend") + tool.Project.get_link_cache_paths(link.filepath, link.query)[0] ) link.is_selectable = (is_selectable := not link.is_selectable) for collection in self.get_linked_collections(): @@ -1862,7 +1878,7 @@ class ToggleLinkVisibility(bpy.types.Operator): props = tool.Project.get_project_props() link = props.links[self.link_index] self.library_filepath = tool.Blender.ensure_blender_path_is_abs( - Path(link.filepath).with_suffix(".ifc.cache.blend") + tool.Project.get_link_cache_paths(link.filepath, link.query)[0] ) if self.mode == "WIREFRAME": self.toggle_wireframe(link) @@ -2266,7 +2282,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): tool.Loader.guess_false_origin(self.file) tool.Georeference.set_model_origin() - self.json_filepath = self.filepath + ".cache.json" + self.json_filepath = str(tool.Project.get_link_cache_paths(self.filepath, self.query)[1]) data = { "model_is_georeferenced": gprops.model_is_georeferenced, "model_crs": gprops.model_crs, @@ -2790,14 +2806,22 @@ class AppendInspectedLinkedElement(AppendLibraryElement): # is displayed rather than at its original coordinates. obj = tool.Ifc.get_object(element) if isinstance(obj, bpy.types.Object): + # Prefer matching the link by the queried instance's root empty - + # the same file may be linked several times (different queries) + # and moved to different locations. + root = props.queried_obj_root linked_filepath = Path(queried_obj["ifc_filepath"]) + link_match = None for link in props.links: - if Path(tool.Ifc.resolve_uri(link.filepath)) != linked_filepath: - continue - delta = tool.Project.calculate_link_delta_matrix(link) + if root is not None and tool.Project.get_link_empty_handle(link) == root: + link_match = link + break + if link_match is None and Path(tool.Ifc.resolve_uri(link.filepath)) == linked_filepath: + link_match = link + if link_match: + delta = tool.Project.calculate_link_delta_matrix(link_match) if not delta.is_identity: obj.matrix_world = delta @ obj.matrix_world - break return {"FINISHED"} diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 7f9ea5b196..044aa29e79 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -18,6 +18,7 @@ from __future__ import annotations +import hashlib import json import os import shutil @@ -89,10 +90,25 @@ class Project(bonsai.core.tool.Project): else: link.empty_handle = empty + @classmethod + def get_link_cache_paths(cls, filepath: Union[Path, str], query: str) -> tuple[Path, Path]: + """Get the (blend, json) cache paths for a linked model loaded with ``query``. + + Cache files are per-query so the same IFC file can be linked several + times with different queries without the caches overwriting each + other. An empty query keeps the legacy un-suffixed names. + """ + filepath = Path(filepath) + suffix = "" if not query else "." + hashlib.md5(query.encode("utf-8")).hexdigest()[:8] + return ( + filepath.with_suffix(f".ifc.cache{suffix}.blend"), + filepath.with_suffix(f".ifc.cache{suffix}.json"), + ) + @classmethod def calculate_link_matrix(cls, link: Link) -> Matrix: filepath = Path(tool.Ifc.resolve_uri(link.filepath)) - with open(filepath.with_suffix(".ifc.cache.json"), "r") as f: + with open(cls.get_link_cache_paths(filepath, link.query)[1], "r") as f: metadata = json.load(f) rot = ifcopenshell.util.shape_builder.np_rotation_matrix( @@ -151,7 +167,7 @@ class Project(bonsai.core.tool.Project): new_obj_matrix = np.array(obj.matrix_world) filepath = Path(tool.Ifc.resolve_uri(link.filepath)) - with open(filepath.with_suffix(".ifc.cache.json"), "r") as f: + with open(cls.get_link_cache_paths(filepath, link.query)[1], "r") as f: metadata = json.load(f) rot = ifcopenshell.util.shape_builder.np_rotation_matrix( @@ -387,27 +403,40 @@ class Project(bonsai.core.tool.Project): def load_linked_models_from_ifc(cls) -> None: links = tool.Project.get_project_props().links links.clear() + references: list[ifcopenshell.entity_instance] = [] for doc in tool.Ifc.get().by_type("IfcDocumentInformation"): if doc.Scope != "LINKED_MODEL": continue - for reference in tool.Drawing.get_document_references(doc): - filepath = reference.Location - link = links.add() - link.name = filepath - link.filepath = filepath - link.ifc_definition_id = reference.id() - link.has_transformation = False - 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. + references.extend(tool.Drawing.get_document_references(doc)) + location_counts: defaultdict[str, int] = defaultdict(int) + for reference in references: + location_counts[reference.Location] += 1 + for reference in references: + filepath = reference.Location + link = links.add() + link.name = filepath + link.filepath = filepath + link.ifc_definition_id = reference.id() + link.has_transformation = False + 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 per + # reference in its Description (IFC4+); restore it so + # Reload/Load replay the filter. + query = getattr(reference, "Description", None) or "" + if not query and location_counts[filepath] == 1: + # Fall back to the legacy sidecar cache JSON where older + # versions persisted the query. Only unambiguous: with + # several links to one file the shared JSON can't say + # which link it belonged to. 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", "") + query = json.loads(json_filepath.read_text()).get("query", "") except (OSError, json.JSONDecodeError): pass + link.query = query @classmethod def get_project_library_elements(