mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-18 14:31:39 +00:00
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:
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user