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 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-07-02 22:06:44 -05:00
parent cdb594b5c2
commit 6d90048acd
2 changed files with 84 additions and 31 deletions
@@ -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 = ifcopenshell.api.document.add_reference(tool.Ifc.get(), information=document)
reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()]) reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()])
reference.Location = filepath.replace("\\", "/") 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.ifc_definition_id = reference.id()
new.name = filepath new.name = filepath
new.filepath = filepath new.filepath = filepath
@@ -1487,9 +1490,12 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file" 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") link_index: bpy.props.IntProperty(name="Link Index")
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) use_cache: bpy.props.BoolProperty(name="Use Cache", default=True, options={"SKIP_SAVE"})
query: bpy.props.StringProperty() query: bpy.props.StringProperty(options={"SKIP_SAVE"})
if TYPE_CHECKING: if TYPE_CHECKING:
link_index: int link_index: int
@@ -1538,22 +1544,22 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
self.link.is_loaded = False self.link.is_loaded = False
def link_ifc(self) -> Union[set[str], None]: def link_ifc(self) -> Union[set[str], None]:
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend") blend_filepath, json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query)
h5_filepath = self.filepath_.with_suffix(".ifc.cache.h5")
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
def should_clear_cache() -> bool: def should_clear_cache() -> bool:
if not self.use_cache: if not self.use_cache:
return True return True
if not blend_filepath.exists(): if not blend_filepath.exists():
return False return False
if not json_filepath.exists():
return True
data = json.loads(json_filepath.read_text()) data = json.loads(json_filepath.read_text())
# Empty 'query' - model loaded without custom query. # Empty 'query' - model loaded without custom query.
# Missing 'query' - model was loaded before custom queries were introduced in Bonsai. # Missing 'query' - model was loaded before custom queries were introduced in Bonsai.
query = data.get("query", "") query = data.get("query", "")
return query != self.query return query != self.query
if should_clear_cache(): if should_clear_cache() and blend_filepath.exists():
os.remove(blend_filepath) os.remove(blend_filepath)
if not blend_filepath.exists(): if not blend_filepath.exists():
@@ -1630,7 +1636,7 @@ except Exception as e:
if len(tool.Project.get_project_props().links) > 1: if len(tool.Project.get_project_props().links) > 1:
return # Only the first link sets the origin 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(): if not json_filepath.exists():
return return
@@ -1649,8 +1655,7 @@ except Exception as e:
if not (crs_name := (ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}).get("Name", "")): if not (crs_name := (ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}).get("Name", "")):
self.link.georeferenced = "NONE" self.link.georeferenced = "NONE"
return return
reference = tool.Ifc.get().by_id(self.link.ifc_definition_id) json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query)[1]
json_filepath = Path(reference.Location).with_suffix(".ifc.cache.json")
if not json_filepath.exists(): if not json_filepath.exists():
self.link.georeferenced = "NONE" self.link.georeferenced = "NONE"
return return
@@ -1668,20 +1673,26 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file, optionally changing its file path and load options" 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") link_index: bpy.props.IntProperty(name="Link Index")
filepath: bpy.props.StringProperty( filepath: bpy.props.StringProperty(
name="File Path", name="File Path",
description="Path to the linked IFC file", description="Path to the linked IFC file",
options={"SKIP_SAVE"},
) )
use_relative_path: bpy.props.BoolProperty( use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path", name="Use Relative Path",
description="Whether to store linked model path relative to the currently opened IFC file.", description="Whether to store linked model path relative to the currently opened IFC file.",
default=False, default=False,
options={"SKIP_SAVE"},
) )
use_cache: bpy.props.BoolProperty( use_cache: bpy.props.BoolProperty(
name="Use Cache", name="Use Cache",
description="Reuse the cached geometry if it's still valid instead of reprocessing the IFC", description="Reuse the cached geometry if it's still valid instead of reprocessing the IFC",
default=False, default=False,
options={"SKIP_SAVE"},
) )
query: bpy.props.StringProperty( query: bpy.props.StringProperty(
name="Query", 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" "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." "Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
), ),
options={"SKIP_SAVE"},
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -1763,6 +1775,10 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator):
reference.Location = filepath.replace("\\", "/") reference.Location = filepath.replace("\\", "/")
if document := tool.Document.get_reference_document(reference): if document := tool.Document.get_reference_document(reference):
document.Name = Path(filepath).name 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) 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 { 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() props = tool.Project.get_project_props()
link = props.links[self.link_index] link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs( 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) link.is_selectable = (is_selectable := not link.is_selectable)
for collection in self.get_linked_collections(): for collection in self.get_linked_collections():
@@ -1862,7 +1878,7 @@ class ToggleLinkVisibility(bpy.types.Operator):
props = tool.Project.get_project_props() props = tool.Project.get_project_props()
link = props.links[self.link_index] link = props.links[self.link_index]
self.library_filepath = tool.Blender.ensure_blender_path_is_abs( 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": if self.mode == "WIREFRAME":
self.toggle_wireframe(link) self.toggle_wireframe(link)
@@ -2266,7 +2282,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
tool.Loader.guess_false_origin(self.file) tool.Loader.guess_false_origin(self.file)
tool.Georeference.set_model_origin() 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 = { data = {
"model_is_georeferenced": gprops.model_is_georeferenced, "model_is_georeferenced": gprops.model_is_georeferenced,
"model_crs": gprops.model_crs, "model_crs": gprops.model_crs,
@@ -2790,14 +2806,22 @@ class AppendInspectedLinkedElement(AppendLibraryElement):
# is displayed rather than at its original coordinates. # is displayed rather than at its original coordinates.
obj = tool.Ifc.get_object(element) obj = tool.Ifc.get_object(element)
if isinstance(obj, bpy.types.Object): 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"]) linked_filepath = Path(queried_obj["ifc_filepath"])
link_match = None
for link in props.links: for link in props.links:
if Path(tool.Ifc.resolve_uri(link.filepath)) != linked_filepath: if root is not None and tool.Project.get_link_empty_handle(link) == root:
continue link_match = link
delta = tool.Project.calculate_link_delta_matrix(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: if not delta.is_identity:
obj.matrix_world = delta @ obj.matrix_world obj.matrix_world = delta @ obj.matrix_world
break
return {"FINISHED"} return {"FINISHED"}
+44 -15
View File
@@ -18,6 +18,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import os import os
import shutil import shutil
@@ -89,10 +90,25 @@ class Project(bonsai.core.tool.Project):
else: else:
link.empty_handle = empty 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 @classmethod
def calculate_link_matrix(cls, link: Link) -> Matrix: def calculate_link_matrix(cls, link: Link) -> Matrix:
filepath = Path(tool.Ifc.resolve_uri(link.filepath)) 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) metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix( 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) new_obj_matrix = np.array(obj.matrix_world)
filepath = Path(tool.Ifc.resolve_uri(link.filepath)) 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) metadata = json.load(f)
rot = ifcopenshell.util.shape_builder.np_rotation_matrix( 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: def load_linked_models_from_ifc(cls) -> None:
links = tool.Project.get_project_props().links links = tool.Project.get_project_props().links
links.clear() links.clear()
references: list[ifcopenshell.entity_instance] = []
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"): for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
if doc.Scope != "LINKED_MODEL": if doc.Scope != "LINKED_MODEL":
continue continue
for reference in tool.Drawing.get_document_references(doc): references.extend(tool.Drawing.get_document_references(doc))
filepath = reference.Location location_counts: defaultdict[str, int] = defaultdict(int)
link = links.add() for reference in references:
link.name = filepath location_counts[reference.Location] += 1
link.filepath = filepath for reference in references:
link.ifc_definition_id = reference.id() filepath = reference.Location
link.has_transformation = False link = links.add()
if reference[1]: link.name = filepath
m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4) link.filepath = filepath
link.has_transformation = not np.allclose(m, np.eye(4)) link.ifc_definition_id = reference.id()
# The selector query used at link time is persisted only in the link.has_transformation = False
# sidecar cache JSON; restore it so Reload/Load replay the filter. 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") json_filepath = Path(tool.Ifc.resolve_uri(filepath)).with_suffix(".ifc.cache.json")
if json_filepath.exists(): if json_filepath.exists():
try: try:
link.query = json.loads(json_filepath.read_text()).get("query", "") query = json.loads(json_filepath.read_text()).get("query", "")
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
pass pass
link.query = query
@classmethod @classmethod
def get_project_library_elements( def get_project_library_elements(