mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
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:
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user