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: