Bonsai: auto-load links that were loaded and visible at save time

At IFC save time each link reference Description gains a loaded flag
(is_loaded and not is_hidden), extending the same JSON blob that
carries the include/exclude filter; plain legacy strings decode as
no-autoload. On project open, load_linked_models_from_ifc replays
flagged links via load_link after restoring the list, warning and
skipping missing files so they cannot break the open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-07-11 09:08:51 -05:00
parent 429cab8b1b
commit e0b97c574b
3 changed files with 64 additions and 13 deletions
+13
View File
@@ -134,6 +134,19 @@ default set when empty) exclude, applied in `LoadLinkedProject` and per link
same file with a different filter gets its own cache; both filters survive
save → reopen → reload.
### Auto-load on open
Links that were **loaded and visible** at IFC save time auto-load when the project
is reopened. `ExportIFC` calls `tool.Project.update_linked_models_state()`, which
rewrites each reference's `Description` with a `loaded` flag
(`is_loaded and not is_hidden`); `load_linked_models_from_ifc` replays flagged
links via `load_link` after restoring the list (missing files warn and skip so
they can't break project open). The flag extends the same JSON blob as the
exclude — plain legacy strings decode as no-autoload. Trade-off: project open
pays the link-load cost up front (fast on cache hit; a missing cache rebuilds in
a background Blender, same as clicking Load). Verified headless: loaded+visible
auto-loads; unloaded and loaded-but-hidden links stay unloaded.
### External styles + layerset slicing in the linked loader
`LoadLinkedProject.get_external_material(style_id)` resolves a style id → appended
@@ -1438,7 +1438,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()])
reference.Location = filepath.replace("\\", "/")
# Persist the filter per reference (Description is IFC4+ only).
description = tool.Project.encode_link_filter(self.query, self.exclude)
description = tool.Project.encode_link_filter(self.query, self.exclude, loaded=True)
if description and hasattr(reference, "Description"):
reference.Description = description
new.ifc_definition_id = reference.id()
@@ -1812,7 +1812,7 @@ class ReloadLink(bpy.types.Operator, tool.Ifc.Operator):
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 = tool.Project.encode_link_filter(link.query, link.exclude)
reference.Description = tool.Project.encode_link_filter(link.query, link.exclude, loaded=True)
bpy.ops.bim.unload_link(link_index=self.link_index)
return bpy.ops.bim.load_link(
@@ -2162,6 +2162,8 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
# gizmo polls gate on each preview's is_active flag, and a stuck flag
# persisted through the save would silently hide them on reload.
preview_base.discard_pending_previews(context.scene)
# Links loaded and visible right now auto-load on the next open.
tool.Project.update_linked_models_state()
# Suffix is appended to the IFC save-success report below so the auto-commit
# info isn't immediately overwritten by the success message in Blender's
# status bar (only the latest self.report({"INFO"}, ...) sticks).
+47 -11
View File
@@ -113,29 +113,55 @@ class Project(bonsai.core.tool.Project):
)
@classmethod
def encode_link_filter(cls, query: str, exclude: str) -> Union[str, None]:
"""Serialize a link's filter for IfcDocumentReference.Description.
def encode_link_filter(cls, query: str, exclude: str, loaded: bool = False) -> Union[str, None]:
"""Serialize a link's filter and state for IfcDocumentReference.Description.
A plain include query is stored as-is (backwards compatible); an
exclude promotes the value to a small JSON blob.
exclude or a loaded state promotes the value to a small JSON blob.
The loaded flag makes the link auto-load on the next project open.
"""
if exclude:
return json.dumps({"include": query, "exclude": exclude})
if exclude or loaded:
return json.dumps({"include": query, "exclude": exclude, "loaded": loaded})
return query or None
@classmethod
def decode_link_filter(cls, description: Union[str, None]) -> tuple[str, str]:
"""Get (query, exclude) from a reference Description written by encode_link_filter."""
def decode_link_filter(cls, description: Union[str, None]) -> tuple[str, str, bool]:
"""Get (query, exclude, loaded) from a Description written by encode_link_filter."""
if not description:
return "", ""
return "", "", False
if description.startswith("{"):
try:
data = json.loads(description)
if isinstance(data, dict):
return data.get("include", "") or "", data.get("exclude", "") or ""
return (
data.get("include", "") or "",
data.get("exclude", "") or "",
bool(data.get("loaded", False)),
)
except json.JSONDecodeError:
pass
return description, ""
return description, "", False
@classmethod
def update_linked_models_state(cls) -> None:
"""Persist each link's loaded/visible state onto its document reference.
Called at IFC save time so links that were loaded and visible
auto-load the next time the project is opened.
"""
if not tool.Ifc.get():
return
for link in cls.get_project_props().links:
if not link.ifc_definition_id:
continue
try:
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
except RuntimeError:
continue
if hasattr(reference, "Description"):
reference.Description = cls.encode_link_filter(
link.query, link.exclude, loaded=link.is_loaded and not link.is_hidden
)
@classmethod
def calculate_link_matrix(cls, link: Link) -> Matrix:
@@ -463,6 +489,7 @@ class Project(bonsai.core.tool.Project):
location_counts: defaultdict[str, int] = defaultdict(int)
for reference in references:
location_counts[reference.Location] += 1
autoload_indices: list[int] = []
for reference in references:
filepath = reference.Location
link = links.add()
@@ -476,7 +503,7 @@ class Project(bonsai.core.tool.Project):
# The selector filter used at link time is persisted per
# reference in its Description (IFC4+); restore it so
# Reload/Load replay the filter.
query, exclude = cls.decode_link_filter(getattr(reference, "Description", None))
query, exclude, loaded = cls.decode_link_filter(getattr(reference, "Description", None))
if not query and not exclude and location_counts[filepath] == 1:
# Fall back to the legacy sidecar cache JSON where older
# versions persisted the query. Only unambiguous: with
@@ -490,6 +517,15 @@ class Project(bonsai.core.tool.Project):
pass
link.query = query
link.exclude = exclude
if loaded:
autoload_indices.append(len(links) - 1)
# Links that were loaded and visible at save time load automatically.
for i in autoload_indices:
if not Path(tool.Ifc.resolve_uri(links[i].filepath)).exists():
print(f"WARNING: Not auto-loading missing linked model: {links[i].filepath}")
continue
bpy.ops.bim.load_link(link_index=i)
@classmethod
def get_project_library_elements(