Add status assignment to Statuses UI

Example - https://files.catbox.moe/c6ydj7.mp4

Still need to add tests and support for userdefined statuses.
This commit is contained in:
Andrej730
2025-08-15 18:32:19 +05:00
parent f5cb2dd21f
commit bd0e16cee5
7 changed files with 215 additions and 33 deletions
@@ -38,6 +38,7 @@ classes = (
operator.AssignProduct, operator.AssignProduct,
operator.AssignRecurrencePattern, operator.AssignRecurrencePattern,
operator.AssignSuccessor, operator.AssignSuccessor,
operator.AssignStatus,
operator.AssignWorkSchedule, operator.AssignWorkSchedule,
operator.Bonsai_DatePicker, operator.Bonsai_DatePicker,
operator.CalculateTaskDuration, operator.CalculateTaskDuration,
@@ -32,6 +32,7 @@ def refresh():
TaskICOMData.is_loaded = False TaskICOMData.is_loaded = False
WorkScheduleData.is_loaded = False WorkScheduleData.is_loaded = False
AnimationColorSchemeData.is_loaded = False AnimationColorSchemeData.is_loaded = False
StatusData.is_loaded = False
class SequenceData: class SequenceData:
@@ -421,3 +422,25 @@ class AnimationColorSchemeData:
except: except:
pass pass
return [(str(g.id()), g.Name or "Unnamed", "") for g in sorted(results, key=lambda x: x.Name or "Unnamed")] return [(str(g.id()), g.Name or "Unnamed", "") for g in sorted(results, key=lambda x: x.Name or "Unnamed")]
class StatusData:
data: dict[str, Any] = {}
is_loaded = False
@classmethod
def load(cls) -> None:
cls.is_loaded = True
cls.data = {
"statuses_with_elements": cls.statuses_with_elements(),
}
@classmethod
def statuses_with_elements(cls) -> set[str]:
statuses = ["No Status"]
statuses.extend(tool.Sequence.ELEMENT_STATUSES)
statuses_used: set[str] = set()
for status in statuses:
if tool.Sequence.get_elements_by_status(status):
statuses_used.add(status)
return statuses_used
+155 -22
View File
@@ -18,22 +18,26 @@
# pyright: reportUnnecessaryTypeIgnoreComment=error # pyright: reportUnnecessaryTypeIgnoreComment=error
import os import types
from collections import Counter
from functools import cache
import bpy import bpy
import json
import time import time
import calendar import calendar
import isodate import ifcopenshell.api.pset
import ifcopenshell.util.element
import bonsai.bim.schema
import bonsai.core.sequence as core import bonsai.core.sequence as core
import bonsai.tool as tool import bonsai.tool as tool
import bonsai.bim.module.sequence.helper as helper
import ifcopenshell.util.sequence
import ifcopenshell.util.selector
from datetime import datetime from datetime import datetime
from dateutil import parser, relativedelta from dateutil import parser, relativedelta
from bpy_extras.io_utils import ImportHelper, ExportHelper from bpy_extras.io_utils import ImportHelper, ExportHelper
from typing import get_args, TYPE_CHECKING, assert_never from typing import Union, get_args, TYPE_CHECKING, assert_never
if TYPE_CHECKING:
from bpy.stub_internal.rna_enums import OperatorReturnItems
class EnableStatusFilters(bpy.types.Operator): class EnableStatusFilters(bpy.types.Operator):
@@ -48,25 +52,33 @@ class EnableStatusFilters(bpy.types.Operator):
props.statuses.clear() props.statuses.clear()
statuses = set() statuses_used: Counter[str] = Counter()
user_defined_statuses: set[str] = set()
for element in tool.Ifc.get().by_type("IfcPropertyEnumeratedValue"): for element in tool.Ifc.get().by_type("IfcPropertyEnumeratedValue"):
if element.Name == "Status": if element.Name == "Status":
if element.PartOfPset and isinstance(element.EnumerationValues, tuple): enum_values = element.EnumerationValues
if element.PartOfPset and isinstance(enum_values, tuple):
pset = element.PartOfPset[0] pset = element.PartOfPset[0]
if pset.Name.startswith("Pset_") and pset.Name.endswith("Common"): pset_name: str = pset.Name
statuses.update(element.EnumerationValues) if pset_name.startswith("Pset_") and pset_name.endswith("Common"):
elif pset.Name == "EPset_Status": # Our secret sauce statuses_used.update([s.wrappedValue for s in enum_values])
statuses.update(element.EnumerationValues) elif pset_name == "EPset_Status": # Our secret sauce
statuses_used.update([s.wrappedValue for s in enum_values])
elif element.Name == "UserDefinedStatus": elif element.Name == "UserDefinedStatus":
statuses.add(element.NominalValue) status: str = element.NominalValue.wrappedValue
statuses_used[element.NominalValue.wrappedValue] += 1
user_defined_statuses.add(status)
statuses = ["No Status"] + sorted([s.wrappedValue for s in statuses]) statuses = ["No Status"]
statuses.extend(tool.Sequence.ELEMENT_STATUSES)
statuses.extend(user_defined_statuses)
for status in statuses: for status in statuses:
new = props.statuses.add() new = props.statuses.add()
new.name = status new.name = status
if new.name in hidden_statuses: if new.name in hidden_statuses:
new.is_visible = False new.is_visible = False
new.has_elements = bool(statuses_used[status])
visible_statuses = {s.name for s in props.statuses if s.is_visible} visible_statuses = {s.name for s in props.statuses if s.is_visible}
tool.Sequence.set_visibility_by_status(visible_statuses) tool.Sequence.set_visibility_by_status(visible_statuses)
@@ -124,19 +136,140 @@ class SelectStatusFilter(bpy.types.Operator):
bl_label = "Select Status Filter" bl_label = "Select Status Filter"
bl_description = "Select elements with currently selected status" bl_description = "Select elements with currently selected status"
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
name: bpy.props.StringProperty()
def execute(self, context): status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
query = f"IfcProduct, /Pset_.*Common/.Status={self.name} + IfcProduct, EPset_Status.Status={self.name}"
if self.name == "No Status": if TYPE_CHECKING:
query = f"IfcProduct, /Pset_.*Common/.Status=NULL, EPset_Status.Status=NULL" status: tool.Sequence.ElementStatusUI
for element in ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), query):
def execute(self, context) -> set["OperatorReturnItems"]:
for element in tool.Sequence.get_elements_by_status(self.status):
obj = tool.Ifc.get_object(element) obj = tool.Ifc.get_object(element)
if obj: if isinstance(obj, bpy.types.Object):
obj.select_set(True) obj.select_set(True)
return {"FINISHED"} return {"FINISHED"}
class AssignStatus(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_status"
bl_label = "Assign Status"
bl_description = "Assign status to the selected elements.\n\nAlt+CLICK to unassign the status."
bl_options = {"REGISTER", "UNDO"}
should_override_previous_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Override Previous Status",
description=(
"Whether assigning new status should override previous one.\n\n"
"IFC allows storing multiple statuses for the same element. "
"This option can be disabled to take advantage of that."
),
default=True,
)
status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
should_unassign_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
options={"SKIP_SAVE"},
)
if TYPE_CHECKING:
should_override_previous_status: bool
status: str
should_unassign_status: bool
def invoke(self, context, event):
self.should_unassign_status = event.alt
return self.execute(context)
def _execute(self, context):
# TODO: UserDefinedStatus
if self.status not in tool.Sequence.ELEMENT_STATUSES:
self.report({"ERROR"}, "Assigning user defined statuses or 'No Status' is not yet supported.")
return {"CANCELLED"}
EPSET_NAME = "EPset_Status"
elements_changed = 0
ifc_file = tool.Ifc.get()
@cache
def get_common_pset_name(element: ifcopenshell.entity_instance) -> Union[str, None]:
templates = bonsai.bim.schema.ifc.psetqto.get_applicable(
element.is_a(),
pset_only=True,
schema=tool.Ifc.get_schema(),
)
for template in templates:
template_name = template.Name
if template_name.startswith("Pset_") and template_name.endswith("Common"):
return template_name
for obj in tool.Blender.get_selected_objects():
if not (element := tool.Ifc.get_entity(obj)) or not element.is_a("IfcProduct"):
continue
psets = ifcopenshell.util.element.get_psets(element, psets_only=True)
common_pset_name = get_common_pset_name(element)
existing_psets = [
pset_name for pset_name in psets if pset_name == EPSET_NAME or pset_name == common_pset_name
]
assert len(existing_psets) < 3
# Common pset comes first.
existing_psets.sort(key=lambda x: x == EPSET_NAME)
if not existing_psets:
if self.should_unassign_status:
continue
pset_name = common_pset_name or EPSET_NAME
pset = ifcopenshell.api.pset.add_pset(ifc_file, element, pset_name)
ifcopenshell.api.pset.edit_pset(ifc_file, pset, properties={"Status": [self.status]})
elements_changed += 1
continue
pset_changed = False
for pset_i, pset_name in enumerate(existing_psets):
pset_data = psets[pset_name]
# None is kind of theoretical.
status_data: Union[list[str], None, types.EllipsisType]
status_data = pset_data.get("Status", ...)
if self.should_unassign_status:
if status_data is ... or not status_data:
continue
# Already unassigned.
if self.status not in status_data:
continue
status_data.remove(self.status)
else:
if status_data is None or status_data is ...:
status_data = [self.status]
elif self.status in status_data:
# Already assigned.
continue
else:
if self.should_override_previous_status:
status_data = [self.status]
else:
status_data.append(self.status)
if pset_i > 0:
# Try to maintain status in just 1 pset.
if not self.should_unassign_status:
status_data.remove(self.status)
ifcopenshell.api.pset.edit_pset(
ifc_file, pset=ifc_file.by_id(pset_data["id"]), properties={"Status": status_data}
)
pset_changed = True
elements_changed += pset_changed
self.report(
{"INFO"},
f"Status '{self.status}' "
f"{'un' * self.should_unassign_status}assigned {'from' if self.should_unassign_status else 'to'} "
f"{elements_changed} elements.",
)
class AddWorkPlan(bpy.types.Operator, tool.Ifc.Operator): class AddWorkPlan(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_work_plan" bl_idname = "bim.add_work_plan"
bl_label = "Add Work Plan" bl_label = "Add Work Plan"
@@ -41,7 +41,7 @@ from bpy.props import (
FloatVectorProperty, FloatVectorProperty,
CollectionProperty, CollectionProperty,
) )
from typing import TYPE_CHECKING, Literal, get_args from typing import TYPE_CHECKING, Literal, Union, get_args
def getTaskColumns(self, context): def getTaskColumns(self, context):
@@ -429,13 +429,13 @@ class BIMWorkPlanProperties(PropertyGroup):
class IFCStatus(PropertyGroup): class IFCStatus(PropertyGroup):
name: StringProperty(name="Name") name: StringProperty() # pyright: ignore[reportRedeclaration]
is_visible: BoolProperty( is_visible: BoolProperty( # pyright: ignore[reportRedeclaration]
name="Is Visible", default=True, update=lambda x, y: (None, bpy.ops.bim.activate_status_filters())[0] name="Is Visible", default=True, update=lambda x, y: (None, bpy.ops.bim.activate_status_filters())[0]
) )
if TYPE_CHECKING: if TYPE_CHECKING:
name: str name: tool.Sequence.ElementStatusUI
is_visible: bool is_visible: bool
+13 -3
View File
@@ -29,6 +29,7 @@ from bonsai.bim.module.sequence.data import (
SequenceData, SequenceData,
TaskICOMData, TaskICOMData,
AnimationColorSchemeData, AnimationColorSchemeData,
StatusData,
) )
from typing import Any, Optional, TYPE_CHECKING from typing import Any, Optional, TYPE_CHECKING
@@ -59,16 +60,25 @@ class BIM_PT_status(Panel):
row.operator("bim.enable_status_filters", icon="GREASEPENCIL") row.operator("bim.enable_status_filters", icon="GREASEPENCIL")
return return
if not StatusData.is_loaded:
StatusData.load()
row = self.layout.row(align=True) row = self.layout.row(align=True)
row.label(text="Statuses found in the project:") row.label(text="Elements Statuses:")
row.operator("bim.activate_status_filters", icon="FILE_REFRESH", text="") row.operator("bim.activate_status_filters", icon="FILE_REFRESH", text="")
row.operator("bim.disable_status_filters", icon="CANCEL", text="") row.operator("bim.disable_status_filters", icon="CANCEL", text="")
box = self.layout.box()
for status in self.props.statuses: for status in self.props.statuses:
row = self.layout.row(align=True) row = box.row(align=True)
row.label(text=status.name) row.label(text=status.name)
if status.name in StatusData.data["statuses_with_elements"]:
row.label(text="", icon="ASSET_MANAGER")
row.prop(status, "is_visible", text="", emboss=False, icon="HIDE_OFF" if status.is_visible else "HIDE_ON") row.prop(status, "is_visible", text="", emboss=False, icon="HIDE_OFF" if status.is_visible else "HIDE_ON")
row.operator("bim.select_status_filter", icon="RESTRICT_SELECT_OFF", text="").name = status.name row.operator("bim.select_status_filter", icon="RESTRICT_SELECT_OFF", text="").status = status.name
row.operator("bim.assign_status", icon="BRUSH_DATA", text="").status = status.name
# TODO: also add a prop to add custom userdefined status.
class BIM_PT_work_plans(Panel): class BIM_PT_work_plans(Panel):
+18 -4
View File
@@ -1840,15 +1840,29 @@ class Sequence(bonsai.core.tool.Sequence):
return isodate.datetime_isoformat(datetime_) return isodate.datetime_isoformat(datetime_)
return isodate.date_isoformat(datetime_) return isodate.date_isoformat(datetime_)
ElementStatus = Literal["NEW", "EXISTING", "DEMOLISH", "TEMPORARY", "OTHER", "NOTKNOWN", "UNSET"]
ElementStatusUI = Union[ElementStatus, Literal["No Status"], str]
"""Also allows UserDefinedStatus from EPset."""
ELEMENT_STATUSES = ("NEW", "EXISTING", "DEMOLISH", "TEMPORARY", "OTHER", "NOTKNOWN", "UNSET")
@classmethod
def get_status_query(cls, status: ElementStatusUI) -> str:
if status == "No Status":
return f"IfcProduct, /Pset_.*Common/.Status=NULL, EPset_Status.Status=NULL"
return f"IfcProduct, /Pset_.*Common/.Status={status} + IfcProduct, EPset_Status.Status={status}"
@classmethod
def get_elements_by_status(cls, status: ElementStatusUI) -> set[ifcopenshell.entity_instance]:
query = cls.get_status_query(status)
return ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), query)
@classmethod @classmethod
def set_visibility_by_status(cls, visible_statuses: set[str]) -> None: def set_visibility_by_status(cls, visible_statuses: set[str]) -> None:
assert bpy.context.view_layer assert bpy.context.view_layer
query = [] query = []
for name in visible_statuses: for name in visible_statuses:
if name == "No Status": q = cls.get_status_query(name)
q = f"IfcProduct, /Pset_.*Common/.Status=NULL, EPset_Status.Status=NULL"
else:
q = f"IfcProduct, /Pset_.*Common/.Status={name} + IfcProduct, EPset_Status.Status={name}"
query.append(q) query.append(q)
query = " + ".join(query) query = " + ".join(query)
@@ -78,6 +78,7 @@ class PsetQto:
qto_only=False, qto_only=False,
schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4", schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4",
) -> list[entity_instance]: ) -> list[entity_instance]:
"""Get applicable property set templates."""
any_class = not ifc_class any_class = not ifc_class
entity = None entity = None
if not any_class: if not any_class: