properties: real attributes + relationships; keep selection on deselect

Add helpers to src/helpers/element: get_scalar_attributes (primitive
EXPRESS attributes only — entity refs / aggregates omitted), get_type
and get_container (ports of ifcopenshell.util.element), and a public
get_string_attribute for safe by-name reads.

Wire them into the properties panel:
- Attributes section shows the element's direct primitive attributes for
  live entities, or cached GlobalId / Name for geometry-only elements.
- Relationships section shows the construction Type and spatial Container
  by name (falling back to the class when unnamed).
- Placeholders are cleared once a project is loaded, so no mock data leaks.

Also: a deselect (click on empty space -> object_id 0) no longer resets
the panel; it keeps showing the last active object. Project reset/open
still clear it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-08 15:14:13 +10:00
parent c5ea612110
commit 8ac7b4373e
3 changed files with 203 additions and 30 deletions
+34 -13
View File
@@ -35,6 +35,10 @@ PropertiesPanelView::PropertiesPanelView(PropertiesPanel* widget,
: QObject(parent), widget_(widget), session_state_(session_state)
{
connect(session_state_, &bonsaiviewer::SessionState::selectionChanged, this, [this](uint32_t object_id) {
// A deselect (click on empty space → object_id 0) leaves the panel
// showing the last active object rather than resetting to the empty
// placeholder. Project reset/open below still clear it explicitly.
if (object_id == 0) return;
refresh(object_id);
});
connect(session_state_, &bonsaiviewer::SessionState::projectReset, this, [this]() {
@@ -92,10 +96,12 @@ void PropertiesPanelView::refresh(uint32_t object_id) {
return;
}
// A real project is loaded — don't leak the placeholder predefined type.
// It's populated below from live IFC data, or set to "N/A" for
// geometry-only elements that have no data to read it from.
// A real project is loaded — don't leak the placeholder attributes /
// relationships / predefined type. They're populated below from live IFC
// data, or from the cached basics for geometry-only elements.
state.entity.predefined_type.clear();
state.attributes.clear();
state.relationships.clear();
auto entity = registry->findEntity(object_id);
if (entity) {
@@ -103,14 +109,32 @@ void PropertiesPanelView::refresh(uint32_t object_id) {
if (auto predefined_type = get_predefined_type(*entity)) {
state.entity.predefined_type = QString::fromStdString(*predefined_type);
}
// Direct EXPRESS attributes, primitives only — lists / entity refs omitted.
for (const auto& [name, value] : get_scalar_attributes(*entity)) {
state.attributes.append({QString::fromStdString(name), QString::fromStdString(value)});
}
// Relationships: the construction type and the spatial container, shown
// by name (falling back to the entity class when unnamed).
auto display_name = [](const express::Base& related) -> QString {
if (auto name = get_string_attribute(related, "Name"); name && !name->empty()) {
return QString::fromStdString(*name);
}
return QString::fromStdString(related.declaration().name());
};
if (express::Base type = get_type(*entity)) {
state.relationships.append({"Type", display_name(type)});
}
if (express::Base container = get_container(*entity)) {
state.relationships.append({"Container", display_name(container)});
}
if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) {
state.property_sets[1].rows[0].value = state.entity.entity_class;
}
} else {
// No live IFC source for this object — typical when a pure-geometry
// .ifcview sidecar was loaded without its .ifc/.rdb sibling. Fall
// back to the basic info cached in the element registry so the
// panel still shows class / name / guid for visible elements.
// .ifcview sidecar was loaded without its .ifc/.rdb sibling. Fall back
// to the basic info cached in the element registry so the panel still
// shows class / GlobalId / Name for visible elements.
auto info = registry->findBasicElementInfo(object_id);
if (info && !info->type.isEmpty()) {
state.entity.entity_class = info->type;
@@ -120,14 +144,11 @@ void PropertiesPanelView::refresh(uint32_t object_id) {
state.property_sets[1].rows[0].value = info->type;
}
}
if (info && !info->name.isEmpty()) {
state.attributes[1].value = info->name;
if (state.property_sets.size() > 1 && state.property_sets[1].rows.size() > 1) {
state.property_sets[1].rows[1].value = info->name;
}
}
if (info && !info->guid.isEmpty()) {
state.attributes[0].value = info->guid;
state.attributes.append({"GlobalId", info->guid});
}
if (info && !info->name.isEmpty()) {
state.attributes.append({"Name", info->name});
}
}
widget_->render(state);
+143 -17
View File
@@ -27,7 +27,10 @@
#include "../ifcparse/schema.h"
#include "schema_dispatch.i"
#include <boost/logic/tribool.hpp>
#include <cstddef>
#include <sstream>
#include <type_traits>
namespace {
@@ -46,31 +49,36 @@ std::string schema_name(const express::Base& instance) {
throw ifcopenshell::exception("No helper implementation was built for schema " + name);
}
// getattr(element, name) for a string- or enum-valued attribute. std::nullopt
// when the attribute is not part of this entity's type (Python's absent
// getattr) or when it is IFC null. Reads by name, so it works uniformly across
// the various IfcElement / IfcType* subtypes that carry PredefinedType et al.
std::optional<std::string> get_string_attribute(const express::Base& element,
const std::string& name) {
const ifcopenshell::entity* declaration = element.declaration().as_entity();
if (declaration == nullptr) {
return std::nullopt;
}
const std::ptrdiff_t index = declaration->attribute_index(name);
if (index < 0) {
return std::nullopt;
}
const attribute_value value = element.get_attribute_value(static_cast<std::size_t>(index));
// A primitive scalar attribute value formatted for display, or std::nullopt for
// IFC null and for non-primitive values (entity references, aggregates/lists,
// binary) — which the properties UI omits.
std::optional<std::string> format_scalar(const attribute_value& value) {
if (value.isNull()) {
return std::nullopt;
}
switch (value.type()) {
case ifcopenshell::Argument_STRING:
return static_cast<std::string>(value);
case ifcopenshell::Argument_ENUMERATION: {
const enumeration_reference enumeration = value;
return enumeration.value() ? std::string(enumeration.value()) : std::string();
}
case ifcopenshell::Argument_STRING:
return static_cast<std::string>(value);
case ifcopenshell::Argument_INT:
return std::to_string(static_cast<int>(value));
case ifcopenshell::Argument_DOUBLE: {
std::ostringstream stream;
stream << static_cast<double>(value);
return stream.str();
}
case ifcopenshell::Argument_BOOL:
return static_cast<bool>(value) ? std::string("True") : std::string("False");
case ifcopenshell::Argument_LOGICAL: {
const boost::logic::tribool logical = value;
if (boost::logic::indeterminate(logical)) {
return std::string("UNKNOWN");
}
return static_cast<bool>(logical) ? std::string("True") : std::string("False");
}
default:
return std::nullopt;
}
@@ -129,6 +137,45 @@ std::optional<std::string> get_predefined_type_s(const express::Base& element) {
return predefined_type;
}
// ifcopenshell.util.element.get_aggregate: the aggregate parent, via the
// Decomposes inverse (IfcRelAggregates.RelatingObject).
template <typename Schema>
express::Base get_aggregate_s(const express::Base& element) {
const auto object = element.template as<typename Schema::IfcObjectDefinition>();
if (!object) {
return {};
}
const auto decomposes = object.Decomposes();
if (decomposes.empty()) {
return {};
}
const auto relationship = decomposes.front();
if constexpr (!is_ifc4_or_higher<Schema>::value) {
// IFC2X3 reuses Decomposes for both aggregates and nests.
if (!relationship.template as<typename Schema::IfcRelAggregates>()) {
return {};
}
}
return relationship.RelatingObject();
}
// ifcopenshell.util.element.get_container (should_get_direct=false, no
// ifc_class): the directly containing spatial element, or the container of the
// aggregate parent for an aggregated part.
template <typename Schema>
express::Base get_container_s(const express::Base& element) {
if (const auto product = element.template as<typename Schema::IfcElement>()) {
const auto relationships = product.ContainedInStructure();
if (!relationships.empty()) {
return relationships.front().RelatingStructure();
}
}
if (const express::Base aggregate = get_aggregate_s<Schema>(element)) {
return get_container_s<Schema>(aggregate);
}
return {};
}
} // namespace
std::optional<std::string> get_predefined_type(const express::Base& element) {
@@ -144,3 +191,82 @@ std::optional<std::string> get_predefined_type(const express::Base& element) {
#undef IFCOPENSHELL_DISPATCH
unsupported_schema(name);
}
std::vector<std::pair<std::string, std::string>> get_scalar_attributes(const express::Base& element) {
std::vector<std::pair<std::string, std::string>> result;
if (!element) {
return result;
}
const ifcopenshell::entity* declaration = element.declaration().as_entity();
if (declaration == nullptr) {
return result;
}
// all_attributes() is supertype-first, matching get_attribute_value(index).
const auto& attributes = declaration->all_attributes();
for (std::size_t index = 0; index < attributes.size(); ++index) {
if (auto value = format_scalar(element.get_attribute_value(index))) {
result.emplace_back(attributes[index]->name(), std::move(*value));
}
}
return result;
}
express::Base get_type(const express::Base& element) {
if (!element) {
return {};
}
const std::string name = schema_name(element);
#define IFCOPENSHELL_DISPATCH(Schema, Identifier) \
if (name == Identifier) { \
return get_type_s<Schema>(element); \
}
IFCOPENSHELL_HELPER_FOR_EACH_SCHEMA(IFCOPENSHELL_DISPATCH)
#undef IFCOPENSHELL_DISPATCH
unsupported_schema(name);
}
express::Base get_container(const express::Base& element) {
if (!element) {
return {};
}
const std::string name = schema_name(element);
#define IFCOPENSHELL_DISPATCH(Schema, Identifier) \
if (name == Identifier) { \
return get_container_s<Schema>(element); \
}
IFCOPENSHELL_HELPER_FOR_EACH_SCHEMA(IFCOPENSHELL_DISPATCH)
#undef IFCOPENSHELL_DISPATCH
unsupported_schema(name);
}
// getattr(element, name) for a string- or enum-valued attribute. Reads by name,
// so it works uniformly across the various subtypes that carry a given
// attribute (PredefinedType, Name, ...).
std::optional<std::string> get_string_attribute(const express::Base& element,
const std::string& name) {
if (!element) {
return std::nullopt;
}
const ifcopenshell::entity* declaration = element.declaration().as_entity();
if (declaration == nullptr) {
return std::nullopt;
}
const std::ptrdiff_t index = declaration->attribute_index(name);
if (index < 0) {
return std::nullopt;
}
const attribute_value value = element.get_attribute_value(static_cast<std::size_t>(index));
if (value.isNull()) {
return std::nullopt;
}
switch (value.type()) {
case ifcopenshell::Argument_ENUMERATION: {
const enumeration_reference enumeration = value;
return enumeration.value() ? std::string(enumeration.value()) : std::string();
}
case ifcopenshell::Argument_STRING:
return static_cast<std::string>(value);
default:
return std::nullopt;
}
}
+26
View File
@@ -26,6 +26,8 @@
#include <optional>
#include <string>
#include <utility>
#include <vector>
// Mirrors ifcopenshell.util.element.get_predefined_type. Returns the element's
// PredefinedType, falling back to the user-defined ObjectType / ElementType /
@@ -35,4 +37,28 @@
// IfcObject, or a geometry-only proxy with no live IFC data).
std::optional<std::string> get_predefined_type(const express::Base& element);
// Mirrors ifcopenshell.util.element.get_type: the construction type element of
// an occurrence (via IsTypedBy on IFC4+, IsDefinedBy on IFC2X3). A type element
// returns itself. Empty express::Base when the element is untyped.
express::Base get_type(const express::Base& element);
// Mirrors ifcopenshell.util.element.get_container (indirect, no ifc_class
// filter): the spatial element that contains this element — the directly
// containing spatial structure, or, for an aggregated part, the container of its
// aggregate parent. Empty when uncontained. (The nest / filled-void /
// voided-element branches of the Python original are not ported.)
express::Base get_container(const express::Base& element);
// Safely read a string- or enum-valued attribute by name (Python's getattr).
// std::nullopt when the attribute is absent for this entity's type or IFC null.
std::optional<std::string> get_string_attribute(const express::Base& element,
const std::string& name);
// The element's direct EXPRESS attributes that have a primitive scalar value
// (string / enum / integer / real / boolean / logical), as (name, formatted
// value) pairs in declaration order. Attributes that are entity references,
// aggregates / lists, or unset (IFC null) are omitted — so the caller gets a
// flat, display-ready view with no nested objects.
std::vector<std::pair<std::string, std::string>> get_scalar_attributes(const express::Base& element);
#endif // ELEMENT_H