From 9fd119bf957d989dcbbcbf856e53c46b68022025 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 11 Jul 2026 10:37:57 -0500 Subject: [PATCH] Bonsai: custom display names for links Each link row draws an editable display_name (double-click to rename) with the file path as placeholder while unset, so several links of the same file can be told apart. The name persists in the same Description JSON blob as the filter and loaded state (new name key, written at save time and by reload_link), restores on project open, and plain legacy strings still decode unchanged. Decode tests updated to the four-tuple with a name round-trip case. Co-Authored-By: Claude Fable 5 --- docs/dev-notes/linked-file-features.md | 9 ++++-- .../bonsai/bim/module/project/operator.py | 4 ++- src/bonsai/bonsai/bim/module/project/prop.py | 9 ++++++ src/bonsai/bonsai/bim/module/project/ui.py | 5 ++-- src/bonsai/bonsai/tool/project.py | 30 ++++++++++++------- src/bonsai/test/tool/test_project.py | 21 ++++++++----- 6 files changed, 53 insertions(+), 25 deletions(-) diff --git a/docs/dev-notes/linked-file-features.md b/docs/dev-notes/linked-file-features.md index 445b33989c..7ab00f1d75 100644 --- a/docs/dev-notes/linked-file-features.md +++ b/docs/dev-notes/linked-file-features.md @@ -122,9 +122,12 @@ default set when empty) − exclude, applied in `LoadLinkedProject` and per link alone would let same-include/different-exclude links silently serve each other's geometry. - **Persistence**: `encode_link_filter`/`decode_link_filter` — a plain include is - stored in `Description` as-is (backwards compatible); an exclude promotes the - value to `{"include": …, "exclude": …}` JSON. Decode treats non-JSON as a legacy - include string. + stored in `Description` as-is (backwards compatible); an exclude, a `loaded` + state or a custom display name promotes the value to + `{"include": …, "exclude": …, "loaded": …, "name": …}` JSON. Decode treats + non-JSON as a legacy include string. The display name (`Link.display_name`, + double-click the list row to rename; file path shows as placeholder while + unset) exists to tell apart several links of the same file. - Exclude applies on top of the **default** element set too, so "everything except X" needs no explicit include. - UI labels are **Include**/**Exclude** (matching the drawing pattern), but the diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 7e45950514..cee2540a97 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1812,7 +1812,9 @@ 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, loaded=True) + reference.Description = tool.Project.encode_link_filter( + link.query, link.exclude, loaded=True, display_name=link.display_name + ) bpy.ops.bim.unload_link(link_index=self.link_index) return bpy.ops.bim.load_link( diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index ae9c4c8d22..0d61c96a3e 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -270,6 +270,14 @@ class Link(PropertyGroup): description="Selector query whose matches are excluded when loading the linked model", default="", ) + display_name: StringProperty( + name="Name", + description=( + "Optional display name to tell links apart (e.g. when the same file " + "is linked several times). Shows the file path when empty" + ), + default="", + ) if TYPE_CHECKING: name: str @@ -287,6 +295,7 @@ class Link(PropertyGroup): ifc_definition_id: int query: str exclude: str + display_name: str class EditedObj(PropertyGroup): diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index c200bd18f2..93c5b9d7b9 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -639,7 +639,8 @@ class BIM_UL_links(UIList): if item.has_transformation: row.label(text="", icon="OBJECT_ORIGIN") - row.label(text=item.filepath) + # Double-click to rename; shows the file path while unset. + row.prop(item, "display_name", text="", emboss=False, placeholder=item.filepath) if item.is_editing: row.operator("bim.disable_editing_link", text="", icon="UNLOCKED", emboss=False).link_index = index else: @@ -655,7 +656,7 @@ class BIM_UL_links(UIList): op.link_index = index op.mode = "VISIBLE" else: - row.label(text=item.filepath) + row.prop(item, "display_name", text="", emboss=False, placeholder=item.filepath) class BIM_PT_purge(Panel): diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index 3684d22b2a..efb478c83f 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -113,22 +113,25 @@ class Project(bonsai.core.tool.Project): ) @classmethod - def encode_link_filter(cls, query: str, exclude: str, loaded: bool = False) -> Union[str, None]: + def encode_link_filter( + cls, query: str, exclude: str, loaded: bool = False, display_name: str = "" + ) -> 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 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. + exclude, a loaded state or a display name promotes the value to a + small JSON blob. The loaded flag makes the link auto-load on the + next project open. """ - if exclude or loaded: - return json.dumps({"include": query, "exclude": exclude, "loaded": loaded}) + if exclude or loaded or display_name: + return json.dumps({"include": query, "exclude": exclude, "loaded": loaded, "name": display_name}) return query or None @classmethod - 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.""" + def decode_link_filter(cls, description: Union[str, None]) -> tuple[str, str, bool, str]: + """Get (query, exclude, loaded, display_name) from a Description written by encode_link_filter.""" if not description: - return "", "", False + return "", "", False, "" if description.startswith("{"): try: data = json.loads(description) @@ -137,10 +140,11 @@ class Project(bonsai.core.tool.Project): data.get("include", "") or "", data.get("exclude", "") or "", bool(data.get("loaded", False)), + data.get("name", "") or "", ) except json.JSONDecodeError: pass - return description, "", False + return description, "", False, "" @classmethod def update_linked_models_state(cls) -> None: @@ -160,7 +164,10 @@ class Project(bonsai.core.tool.Project): continue if hasattr(reference, "Description"): reference.Description = cls.encode_link_filter( - link.query, link.exclude, loaded=link.is_loaded and not link.is_hidden + link.query, + link.exclude, + loaded=link.is_loaded and not link.is_hidden, + display_name=link.display_name, ) @classmethod @@ -503,7 +510,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, loaded = cls.decode_link_filter(getattr(reference, "Description", None)) + query, exclude, loaded, display_name = 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 @@ -517,6 +524,7 @@ class Project(bonsai.core.tool.Project): pass link.query = query link.exclude = exclude + link.display_name = display_name if loaded: autoload_indices.append(len(links) - 1) diff --git a/src/bonsai/test/tool/test_project.py b/src/bonsai/test/tool/test_project.py index 7bcbe07055..b0ed991efc 100644 --- a/src/bonsai/test/tool/test_project.py +++ b/src/bonsai/test/tool/test_project.py @@ -506,33 +506,38 @@ class TestGettingLinkedElementGeomSlice: class TestEncodeDecodeLinkFilter: def test_plain_include_round_trip(self): assert subject.encode_link_filter("IfcWall", "") == "IfcWall" - assert subject.decode_link_filter("IfcWall") == ("IfcWall", "", False) + assert subject.decode_link_filter("IfcWall") == ("IfcWall", "", False, "") def test_empty_filter_encodes_to_none(self): assert subject.encode_link_filter("", "") is None - assert subject.decode_link_filter(None) == ("", "", False) - assert subject.decode_link_filter("") == ("", "", False) + assert subject.decode_link_filter(None) == ("", "", False, "") + assert subject.decode_link_filter("") == ("", "", False, "") def test_exclude_promotes_to_json(self): encoded = subject.encode_link_filter('IfcElement, group="X"', 'IfcSlab, parent="Y"') assert encoded.startswith("{") - assert subject.decode_link_filter(encoded) == ('IfcElement, group="X"', 'IfcSlab, parent="Y"', False) + assert subject.decode_link_filter(encoded) == ('IfcElement, group="X"', 'IfcSlab, parent="Y"', False, "") def test_loaded_promotes_to_json(self): encoded = subject.encode_link_filter("IfcWall", "", loaded=True) assert encoded.startswith("{") - assert subject.decode_link_filter(encoded) == ("IfcWall", "", True) + assert subject.decode_link_filter(encoded) == ("IfcWall", "", True, "") def test_loaded_without_filter(self): encoded = subject.encode_link_filter("", "", loaded=True) - assert subject.decode_link_filter(encoded) == ("", "", True) + assert subject.decode_link_filter(encoded) == ("", "", True, "") def test_legacy_non_json_decodes_as_include(self): legacy = 'IfcElement, location="House - Type B"' - assert subject.decode_link_filter(legacy) == (legacy, "", False) + assert subject.decode_link_filter(legacy) == (legacy, "", False, "") def test_malformed_json_decodes_as_include(self): - assert subject.decode_link_filter("{not json") == ("{not json", "", False) + assert subject.decode_link_filter("{not json") == ("{not json", "", False, "") + + def test_display_name_promotes_to_json(self): + encoded = subject.encode_link_filter("IfcWall", "", display_name="North Wing") + assert encoded.startswith("{") + assert subject.decode_link_filter(encoded) == ("IfcWall", "", False, "North Wing") class TestGetLinkCachePaths: