From e0b97c574b87bb9b9febe3efbc79c6879cba3259 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 11 Jul 2026 09:08:51 -0500 Subject: [PATCH] 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 --- docs/dev-notes/linked-file-features.md | 13 +++++ .../bonsai/bim/module/project/operator.py | 6 +- src/bonsai/bonsai/tool/project.py | 58 +++++++++++++++---- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/docs/dev-notes/linked-file-features.md b/docs/dev-notes/linked-file-features.md index 5652d7d319..a8da507bcb 100644 --- a/docs/dev-notes/linked-file-features.md +++ b/docs/dev-notes/linked-file-features.md @@ -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 diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 510bd9e563..7e45950514 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -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). diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 296ae328af..3684d22b2a 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -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(