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 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-07-11 10:37:57 -05:00
parent 94ba41d9ea
commit 9fd119bf95
6 changed files with 53 additions and 25 deletions
+6 -3
View File
@@ -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
@@ -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(
@@ -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):
+3 -2
View File
@@ -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):
+19 -11
View File
@@ -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)
+13 -8
View File
@@ -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: