Add tool.Array service

Top-level array-domain service extracted out of tool.Blender.Modifier.Array.
Owns the BBIM_Array pset graph navigation (constrain_children_to_parent,
remove_constraints, get_modifiers_data, get_children_objects,
get_all_children_objects, get_child_layer_index, bake_children_transform),
plus the Blender-side CHILD_OF constraint lifecycle that ties each child
replica to its parent's transform.

Array's own module gives the parent/child semantics a clean home — array
behaviour was previously scattered between tool.Blender.Modifier and ad-hoc
helpers in bim/module/model/array.py. The relocation eliminates the inline
duplication and gives Bonsai callers a single import surface.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-05-26 23:41:56 +02:00
committed by Thomas Krijnen
parent 80048c11a0
commit c0e2ff7298
2 changed files with 208 additions and 0 deletions
+1
View File
@@ -20,6 +20,7 @@
# ruff: noqa: F401
from bonsai.tool.aggregate import Aggregate
from bonsai.tool.array import Array
from bonsai.tool.attribute import Attribute
from bonsai.tool.bcf import Bcf
from bonsai.tool.blender import Blender
+207
View File
@@ -0,0 +1,207 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Bonsai parametric array service.
Top-level array-domain helpers. The ``BBIM_Array`` pset on a parent ``IfcElement``
holds the list of layers; each layer holds the GUIDs of its child replicas. These
helpers navigate that graph and manage the Blender-side CHILD_OF constraint that
pins children to the parent's matrix_world."""
from __future__ import annotations
import json
from collections.abc import Generator
from typing import TYPE_CHECKING, Any
import bpy
import ifcopenshell
import ifcopenshell.util.element
import bonsai.core.tool
import bonsai.tool as tool
if TYPE_CHECKING:
from ifcopenshell import entity_instance
class Array(bonsai.core.tool.Array):
@classmethod
def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
with bpy.context.temp_override(object=child):
bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT")
@classmethod
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
if not (parent_obj := tool.Ifc.get_object(parent_element)):
return # Filtered out, arrayed void, etc
assert isinstance(parent_obj, bpy.types.Object)
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
constraint = child.constraints.new("CHILD_OF")
constraint.name = "BBIM_Array_CHILD_OF"
assert isinstance(constraint, bpy.types.ChildOfConstraint)
constraint.target = parent_obj
@classmethod
def set_children_lock_state(
cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True
) -> None:
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
children = cls.get_children_objects(modifier_data)
for child_obj in children:
tool.Blender.lock_transform(child_obj, lock_state)
@classmethod
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
children = cls.get_all_children_objects(parent_element)
for child in children:
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
if constraint:
child.constraints.remove(constraint)
@classmethod
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
parent_obj = tool.Ifc.get_object(parent_element)
assert isinstance(parent_obj, bpy.types.Object)
children_objects = list(cls.get_all_children_objects(parent_element))
array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0
return array_objects
@classmethod
def get_all_children_objects(
cls, parent_element: ifcopenshell.entity_instance
) -> Generator[bpy.types.Object, None, None]:
for array_modifier in cls.get_modifiers_data(parent_element):
yield from cls.get_children_objects(array_modifier)
@classmethod
def get_parent_element(cls, element: entity_instance) -> entity_instance | None:
"""Inverse of ``get_all_children_objects``: resolve an array element
back to its parent entity. Returns ``None`` when the element isn't
part of a Bonsai parametric array, or the stored Parent GUID does
not resolve in the current file (this is a data-integrity warning
and is logged to the console)."""
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return None
parent_guid = pset["Parent"]
try:
return tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
print(
f"BBIM_Array.Parent GUID {parent_guid!r} on {element} does not resolve "
f"in the current file — array integrity may be broken."
)
return None
@classmethod
def get_parent_object(cls, element: entity_instance) -> bpy.types.Object | None:
parent_element = cls.get_parent_element(element)
if parent_element is None:
return None
return tool.Ifc.get_object(parent_element)
@classmethod
def get_modifiers_data(cls, parent_element: ifcopenshell.entity_instance) -> Generator[dict[str, Any], None, None]:
array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
yield from json.loads(array_pset["Data"])
@classmethod
def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]:
child_guid: str
for child_guid in modifier_data["children"]:
child_obj = tool.Blender.get_object_from_guid(child_guid)
if child_obj:
yield child_obj
@classmethod
def get_array_root_guid(cls, element: entity_instance) -> str:
"""Walk ``BBIM_Array.Parent`` upwards and return the topmost ancestor's
GlobalId. For an element with no ``BBIM_Array`` pset (independent
window, never arrayed, or former-child after the apply path), returns
the element's own GlobalId — its "family" is just itself."""
current = element
seen: set[str] = set()
while True:
pset = ifcopenshell.util.element.get_pset(current, "BBIM_Array")
parent_guid = pset.get("Parent") if pset else None
if not parent_guid or parent_guid == current.GlobalId or parent_guid in seen:
return current.GlobalId
seen.add(parent_guid)
try:
current = tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
return current.GlobalId
@classmethod
def get_parametric_propagation_targets(cls, element: entity_instance) -> list[entity_instance]:
"""Type-occurrences that should receive parametric updates when
``element`` is edited.
Returns occurrences in ``element``'s Bonsai array family. When
``element`` is not part of any array, returns the type-occurrence
peers that are likewise free of ``BBIM_Array`` (preserving the
bulk-edit-by-type UX for standalone parametric elements). An
occurrence whose ``BBIM_Array`` root differs from ``element``'s root
is excluded — that is the "independent former child" case the array
apply path produces."""
occurrences = tool.Ifc.get_all_element_occurrences(element)
element_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not element_pset:
return [o for o in occurrences if not ifcopenshell.util.element.get_pset(o, "BBIM_Array")]
element_root = cls.get_array_root_guid(element)
return [o for o in occurrences if cls.get_array_root_guid(o) == element_root]
@classmethod
def get_child_layer_index(cls, child_element: entity_instance) -> int | None:
"""Index of the layer that produced ``child_element``, or ``None``
if the child is unparented, missing from the parent's data, or the
parent's pset is unreadable. Total: never raises."""
pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array")
if not pset:
return None
parent_guid = pset.get("Parent")
if not parent_guid or parent_guid == child_element.GlobalId:
return None
try:
parent_element = tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
return None
data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data")
if not data_text:
return None
try:
layers = json.loads(data_text)
except (ValueError, TypeError):
return None
child_guid = child_element.GlobalId
for i, layer in enumerate(layers):
if child_guid in layer.get("children", []):
return i
return None