Add array parametric edit lifecycle + GizmoArrayEdition / Child

Ports the array parametric-edit lifecycle, gizmo group, child guard,
per-layer ARRAY entry icons, and the array bbox decorators
(preview + selection highlight + layer-children) from gizmos-8088.
Restores the array_gizmo icon's positioning + visibility in the
framework's parametric edit row.

Registry (tool/parametric.py):
* EDIT_TYPES adds ParametricObject("array", supports_build_edit_lifecycle=True).
  _ArrayEditMixin in array.py feeds build_edit_lifecycle which auto-
  generates EnableEditingArray / FinishEditingArray / CancelEditingArray
  with the conventional bl_idnames the gizmo references.

tool/blender.py:
* Adds is_array predicate wrapper around tool.Parametric.is_array.
  The registry contract test test_every_entry_has_modifier_predicate
  enforces every EDIT_TYPES entry has a matching is_<name> wrapper on
  tool.Blender.Modifier.

array.py (+1130 LOC port from gizmos-8088):
* _ArrayEditMixin(ParametricEditMixinBase) drives the auto-generated
  enable / finish / cancel lifecycle.
* GizmoArrayEdition: validate + cancel + count display + +/- adjusters
  + method toggle + delete button + per-layer ARRAY entry icons
  (preallocated pool of MAX_LAYER_GIZMOS=8).
* GizmoArrayChild: child-array gizmo for the array-replica case.
* EditArrayFromChild: resolves the spawning layer via
  tool.Array.get_child_layer_index so clicking a child's array gizmo
  opens the layer that produced that child rather than always layer 0
  (the gizmos-8088 source itself hardcoded item=0; HEAD has the helper
  to do it right).
* New operators: EnableEditingArrayItem, ArrayParentGizmoClick,
  ArrayGizmoClick, ToggleArrayMethod, RemoveArrayLayerFromEdit,
  InputArrayCount, AdjustArrayCount.

prop.py: BIMArrayProperties gets per_child_opening BoolProperty
(when the array parent fills a host, give each child its own
opening + filling pair).

Bug fix: guard update_relating_array_from_object against the
cleanup-time None set. _finish_one writes relating_array_object = None
to clear the source-array reference; that fired the update callback,
which dispatched bpy.ops.bim.enable_editing_array(item=self.is_editing).
With is_editing just flipped to False, the bool coerced to 0 and
re-opened layer-0 edit immediately after every validate. The guard
short-circuits on None; item is also fixed to 0 (the bool-as-layer-
index was always meaningless for the legitimate user-pick path).

decorator.py (+312 LOC, all ports from gizmos-8088):
* bbox_world_edges / draw_polyline_segments / _BBOX_EDGES - shared
  geometry helpers usable across array decorators.
* draw_array_layer_children_bbox - green wireframe bbox per child of
  one array layer, drawn inline from a gizmo's draw() so the highlight
  tracks the hover cursor without POST_VIEW lag.
* ArrayPreviewDecorator - faint cyan ghost bboxes at each future
  array instance during the edit lifecycle (offset math mirrors
  Model.regenerate_array, gated on props.is_editing).
* ArraySelectionHighlightDecorator - bounding-box overlay surfacing
  the array family of the selected object. Child selected -> parent
  in special color + siblings in unselected color; parent selected
  (idle) -> all children in unselected color. TokenCache-backed.

handler.py: imports + uninstall/install the 2 always-on decorators in
_install_viewport_overlays. Both self-poll, so installation has no
cost when no array is selected / in edit mode.

Registration (bim/module/model/__init__.py):
* Adds the 3 lifecycle classes generated by build_edit_lifecycle
  (CancelEditingArray, EnableEditingArray, FinishEditingArray) -
  they exist as module-level names but are only visible to Blender's
  operator registry when included in the classes tuple.
* Adds the 8 new operators + 2 new gizmo groups in alphabetical order.

gizmos.py: restores the array_gizmo icon position + visibility block
in BaseParametricGizmoGroup.update_editing_gizmos. Was force-hidden
in c250b2c1a because no array gizmo existed; the icon's plumbing
comes back online now that GizmoArrayEdition is registered.

Verified by test/bim/test_parametric_registry.py: all 8 tests pass -
enable/finish/cancel ops resolve, PropertyGroup attached, is_array
predicate present, predicate is total on non-matching elements.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-01 14:39:58 +02:00
parent 0d3543fa31
commit 9440bafc32
8 changed files with 1521 additions and 74 deletions
+11
View File
@@ -43,6 +43,8 @@ from bonsai.bim.module.aggregate.decorator import AggregateDecorator
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.model.decorator import (
ArrayPreviewDecorator,
ArraySelectionHighlightDecorator,
BoundingBoxDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
@@ -464,6 +466,8 @@ def _install_viewport_overlays() -> None:
WallAxisDecorator.uninstall()
SlabDirectionDecorator.uninstall()
WallFilletPreviewDecorator.uninstall()
ArrayPreviewDecorator.uninstall()
ArraySelectionHighlightDecorator.uninstall()
uninstall_decorator_cache_handlers()
try:
if georeference_props.should_visualise:
@@ -482,6 +486,13 @@ def _install_viewport_overlays() -> None:
# wall_fillet.is_active, so installation has no cost when no preview
# is open. No corresponding addon-preference toggle.
WallFilletPreviewDecorator.install(bpy.context)
# Always-installed: draw() self-polls on the active object's array
# family membership, so installation has no cost when no array
# element is selected.
ArraySelectionHighlightDecorator.install(bpy.context)
# Always-installed: draw() self-polls on props.is_editing — only
# paints during an active array edit lifecycle.
ArrayPreviewDecorator.install(bpy.context)
finally:
install_decorator_cache_handlers()
+18 -7
View File
@@ -5916,14 +5916,25 @@ class BaseParametricGizmoGroup:
billboard_rot=billboard_rot,
scale=0.30,
)
# Array gizmo integration is in-progress: the icon binds to
# bim.add_array_from_feature_edit but the array-from-parametric-draft
# operator + per-feature gizmo positioning haven't fully landed.
# Force-hide the icon while parametric-item editing is active to
# keep the user from triggering a half-wired add-array flow. Drop
# this gate when array integration completes.
# ARRAY button sits past the last feature-specific icon. Each
# gizmo group declares its own ``FEATURE_ICON_MAX_X`` (default
# 0.87 past the cycle slot; wall / stair override it) so the
# ARRAY button never lands on top of a rotate / tread-lock icon.
if hasattr(self, "array_gizmo"):
self.array_gizmo.hide = True
self.array_gizmo.hide = self.is_gizmo_hidden_by_modal(self.array_gizmo)
# 30% smaller than the editing-icon-row default (0.50 → 0.35):
# the array button is a tertiary affordance compared to the
# primary pen / validate / cancel triad, and the smaller
# footprint keeps the edit-mode row from sprawling.
self.set_icon_gizmo_position(
"array_gizmo",
mw=mw,
x=self.ICON_VALIDATE_X + self.FEATURE_ICON_MAX_X + self.ICON_ARRAY_GAP,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
scale=0.35,
)
else:
# ``hide_pen_button = True`` keeps the pen permanently hidden — for
# groups whose edit-mode entry is already provided by another widget
@@ -50,19 +50,31 @@ from . import (
classes = (
array.AddArray,
array.CancelEditingArray,
array.DisableEditingArray,
array.EditArray,
array.EnableEditingArray,
array.EnableEditingArrayItem,
array.FinishEditingArray,
array.ApplyArray,
array.RegenerateArray,
array.RemoveArray,
array.SelectAllArrayObjects,
array.SelectArrayParent,
array.ArrayParentGizmoClick,
array.EditArrayFromChild,
array.Input3DCursorXArray,
array.Input3DCursorYArray,
array.Input3DCursorZArray,
array.EnableEditingParametric,
array.AddArrayFromFeatureEdit,
array.ArrayGizmoClick,
array.ToggleArrayMethod,
array.RemoveArrayLayerFromEdit,
array.InputArrayCount,
array.AdjustArrayCount,
array.GizmoArrayEdition,
array.GizmoArrayChild,
product.AddDefaultType,
product.AddEmptyType,
product.AddOccurrence,
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@
from __future__ import annotations
import json
import math
from math import cos, pi, radians, sin, tan
from typing import Any, Literal
@@ -41,6 +42,7 @@ from mathutils import Matrix, Quaternion, Vector
import bonsai.core.geometry
import bonsai.tool as tool
from bonsai.bim.decorator_cache import TokenCache
from bonsai.bim.module.drawing.helper import format_distance
@@ -2177,3 +2179,313 @@ class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
d1 = (p1.x - intersection[0]) ** 2 + (p1.y - intersection[1]) ** 2 + (p1.z - intersection[2]) ** 2
d2 = (p2.x - intersection[0]) ** 2 + (p2.y - intersection[1]) ** 2 + (p2.z - intersection[2]) ** 2
return p2 if d2 >= d1 else p1
_BBOX_EDGES = (
(0, 1), (1, 2), (2, 3), (3, 0),
(4, 5), (5, 6), (6, 7), (7, 4),
(0, 4), (1, 5), (2, 6), (3, 7),
) # fmt: skip
def bbox_world_edges(
obj: bpy.types.Object,
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
"""Return world-space (start, end) tuples for the 12 edges of ``obj``'s
bounding box. Empty list if the object has no bound_box (e.g. Empties)."""
if not obj.bound_box:
return []
mw = obj.matrix_world
corners = [mw @ Vector(c) for c in obj.bound_box]
return [(tuple(corners[a]), tuple(corners[b])) for a, b in _BBOX_EDGES]
def draw_polyline_segments(
context: bpy.types.Context,
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
color_rgb: tuple[float, float, float],
alpha: float,
line_width: float,
) -> None:
"""Render ``segments`` as one anti-aliased LINES batch in world space."""
if not segments:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int]] = []
for start, end in segments:
base = len(verts)
verts.append(start)
verts.append(end)
indices.append((base, base + 1))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", line_width)
shader.uniform_float("color", (*color_rgb, alpha))
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
_ARRAY_LAYER_BBOX_LINE_WIDTH = 1.8
_ARRAY_LAYER_BBOX_LINE_ALPHA = 0.8
_ARRAY_LAYER_BBOX_MAX_CHILDREN = 200
def draw_array_layer_children_bbox(
context: bpy.types.Context,
parent_element: ifcopenshell.entity_instance,
layer_index: int,
max_children: int = _ARRAY_LAYER_BBOX_MAX_CHILDREN,
) -> None:
"""Paint a wireframe bbox around every child of one array layer in the
same 3D pass. Called inline from gizmo ``draw()`` methods so the highlight
tracks the hover cursor one-for-one no POST_VIEW handler, no timing lag.
Total: silently no-ops on missing pset, unparseable JSON, out-of-range
layer index, unresolvable child GUIDs, or empty child geometry."""
if layer_index < 0:
return
data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data")
if not data_text:
return
try:
layers = json.loads(data_text)
except (ValueError, TypeError):
return
if layer_index >= len(layers):
return
child_guids = layers[layer_index].get("children", [])
if not child_guids:
return
ifc_file = tool.Ifc.get()
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
for guid in child_guids[:max_children]:
try:
child_element = ifc_file.by_guid(guid)
except RuntimeError:
continue
child_obj = tool.Ifc.get_object(child_element)
if child_obj is None:
continue
segments.extend(bbox_world_edges(child_obj))
if not segments:
return
prefs = tool.Blender.get_addon_preferences()
color = prefs.decorator_color_special[:3]
draw_polyline_segments(
context,
segments,
color,
_ARRAY_LAYER_BBOX_LINE_ALPHA,
_ARRAY_LAYER_BBOX_LINE_WIDTH,
)
class ArrayPreviewDecorator(tool.Blender.ViewportDecorator):
"""Faint bbox wireframe at each future array instance during the edit lifecycle.
Pure GPU preview gated on the array's draft props — no IFC mutation."""
LINE_WIDTH = 1.2
LINE_ALPHA = 0.45
MAX_PREVIEW_INSTANCES = 200
def draw(self, context: bpy.types.Context) -> None:
if not tool.Blender.are_viewport_gizmos_enabled():
return
prefs = tool.Blender.get_addon_preferences()
obj = context.active_object
if obj is None or not obj.bound_box:
return
element = tool.Ifc.get_entity(obj)
if not element or not tool.Parametric.is_array(element):
return
props = tool.Model.get_array_props(obj)
if not props.is_editing:
return
count = int(props.count)
if count <= 1 or count > self.MAX_PREVIEW_INSTANCES:
return
segments = self._compute_segments(obj, props, count)
if not segments:
return
color = prefs.decorator_color_selected[:3]
draw_polyline_segments(context, segments, color, self.LINE_ALPHA, self.LINE_WIDTH)
def _compute_segments(
self,
parent_obj: bpy.types.Object,
props,
count: int,
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
"""World-space (start, end) line segments for the bbox edges of
every future instance (i = 1 count-1; i = 0 is the parent itself).
props.x/y/z are SI the edit-lifecycle Enable hydrates them via
si_conversion, so no unit_scale multiplier here."""
offset = Vector((props.x, props.y, props.z))
if props.method == "DISTRIBUTE":
divider = (count - 1) if count > 1 else 1
offset = offset / divider
parent_mw = parent_obj.matrix_world
parent_corners = [Vector(c) for c in parent_obj.bound_box]
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
for i in range(1, count):
delta = offset * i
child_mw = parent_mw.copy()
if props.use_local_space:
child_mw.translation = parent_mw @ delta
else:
child_mw.translation = parent_mw.translation + delta
world_corners = [child_mw @ corner for corner in parent_corners]
for a, b in _BBOX_EDGES:
segments.append((tuple(world_corners[a]), tuple(world_corners[b])))
return segments
class ArraySelectionHighlightDecorator(tool.Blender.ViewportDecorator):
"""Bounding-box overlay surfacing the array family of the selected object.
Two activation modes:
- **Child selected** parent drawn in the addon's *special*
decorator color (bright accent); other siblings in the *unselected*
color at lower alpha so the parent stands out. The selected child
itself keeps Blender's standard selection outline.
- **Parent selected** (idle, not editing) every existing child drawn
in the *unselected* color at lower alpha. The parent is already
visually flagged by Blender's selection outline. Suppressed during
an active array edit lifecycle so the live preview wireframes don't
double-draw with the existing-children overlay."""
LINE_WIDTH = 1.5
PARENT_ALPHA = 0.7
SIBLING_ALPHA = 0.35
MAX_SIBLINGS = 200
def __init__(self) -> None:
self._family_cache: TokenCache = TokenCache()
def draw(self, context: bpy.types.Context) -> None:
if not tool.Blender.are_viewport_gizmos_enabled():
return
prefs = tool.Blender.get_addon_preferences()
obj = context.active_object
if obj is None:
return
if not obj.select_get():
return
element = tool.Ifc.get_entity(obj)
if not element:
return
if tool.Blender.Modifier.is_array_child(element):
self._draw_for_child(context, prefs, element, obj)
elif tool.Parametric.is_array(element):
props = tool.Model.get_array_props(obj)
if not props.is_editing:
self._draw_for_parent(context, prefs, element, obj)
def _draw_for_child(self, context, prefs, element, obj):
family = self._resolve_family_for_child(obj, element)
if family is None:
return
parent_obj, sibling_objs = family
parent_segments = bbox_world_edges(parent_obj)
if parent_segments:
draw_polyline_segments(
context,
parent_segments,
prefs.decorator_color_special[:3],
self.PARENT_ALPHA,
self.LINE_WIDTH,
)
self._draw_siblings(context, prefs, sibling_objs)
def _draw_for_parent(self, context, prefs, element, obj):
child_objs = self._resolve_children_for_parent(obj, element)
self._draw_siblings(context, prefs, child_objs)
def _resolve_family_for_child(self, obj, element):
return self._family_cache.get_or_compute(
("child", obj.session_uid, element.id()),
lambda: self._collect_family_from_child(element, obj),
)
def _resolve_children_for_parent(self, obj, element):
return (
self._family_cache.get_or_compute(
("parent", obj.session_uid, element.id()),
lambda: self._collect_children(element, exclude=obj),
)
or []
)
def _draw_siblings(self, context, prefs, sibling_objs):
if not sibling_objs:
return
if len(sibling_objs) > self.MAX_SIBLINGS:
sibling_objs = sibling_objs[: self.MAX_SIBLINGS]
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
for sib_obj in sibling_objs:
segments.extend(bbox_world_edges(sib_obj))
draw_polyline_segments(
context,
segments,
prefs.decorator_color_unselected[:3],
self.SIBLING_ALPHA,
self.LINE_WIDTH,
)
def _collect_family_from_child(self, element, obj):
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return None
parent_guid = pset.get("Parent")
if not parent_guid:
return None
try:
parent_element = tool.Ifc.get().by_guid(parent_guid)
except RuntimeError:
return None
parent_obj = tool.Ifc.get_object(parent_element)
if not parent_obj:
return None
siblings = self._collect_children(parent_element, exclude=obj, also_exclude=parent_obj)
return parent_obj, siblings
def _collect_children(self, parent_element, exclude=None, also_exclude=None):
parent_data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data")
if not parent_data_text:
return []
try:
layers = json.loads(parent_data_text)
except (ValueError, TypeError):
return []
children: list[bpy.types.Object] = []
seen_ids: set[int] = set()
if exclude is not None:
seen_ids.add(id(exclude))
if also_exclude is not None:
seen_ids.add(id(also_exclude))
for layer in layers:
for child_guid in layer.get("children", []):
try:
child_element = tool.Ifc.get().by_guid(child_guid)
except RuntimeError:
continue
child_obj = tool.Ifc.get_object(child_element)
if child_obj is None or id(child_obj) in seen_ids:
continue
seen_ids.add(id(child_obj))
children.append(child_obj)
return children
+25 -5
View File
@@ -103,8 +103,12 @@ def update_type_page(self: "BIMModelProperties", context: bpy.types.Context) ->
def update_relating_array_from_object(self: "BIMArrayProperties", context: bpy.types.Context) -> None:
bpy.ops.bim.enable_editing_array(item=self.is_editing)
return
# Skip the cleanup-time clear: Finish/Cancel sets relating_array_object back to None,
# which has no source to hydrate from. Only the user-driven pick (None → some array)
# should auto-enter edit on the picked source's layer 0.
if self.relating_array_object is None:
return
bpy.ops.bim.enable_editing_array(item=0)
def is_object_array_applicable(self: "BIMArrayProperties", obj: bpy.types.Object) -> bool:
@@ -397,8 +401,13 @@ class BIMModelProperties(PropertyGroup):
class BIMArrayProperties(PropertyGroup):
is_editing: bpy.props.IntProperty(
default=-1, description="Currently edited array index. -1 if not in array editing mode."
is_editing: bpy.props.BoolProperty(
default=False,
description="True while an array layer is in parametric edit mode. The specific layer is in editing_item_index.",
)
editing_item_index: bpy.props.IntProperty(
default=-1,
description="Index of the array layer currently being edited; -1 when not in edit mode.",
)
count: bpy.props.IntProperty(name="Count", default=0, min=0)
x: bpy.props.FloatProperty(name="X", default=0, subtype="DISTANCE")
@@ -414,6 +423,15 @@ class BIMArrayProperties(PropertyGroup):
name="Method",
default="OFFSET",
)
per_child_opening: bpy.props.BoolProperty(
name="Per-Child Opening",
description=(
"When the array parent fills a wall (or any voidable host), give each array child its own opening + "
"filling pair so the host is cut once per child. Disable to leave the host uncut by the children — "
"only the parent's original opening remains"
),
default=True,
)
relating_array_object: bpy.props.PointerProperty(
type=bpy.types.Object,
name="Copy Array Properties",
@@ -422,13 +440,15 @@ class BIMArrayProperties(PropertyGroup):
)
if TYPE_CHECKING:
is_editing: int
is_editing: bool
editing_item_index: int
count: int
x: float
y: float
z: float
use_local_space: bool
method: Literal["OFFSET", "DISTRIBUTE"]
per_child_opening: bool
sync_children: bool
relating_array_object: Union[bpy.types.Object, None]
+4
View File
@@ -1363,6 +1363,10 @@ class Blender(bonsai.core.tool.Blender):
def is_window(cls, element: entity_instance) -> bool:
return tool.Parametric.is_window(element)
@classmethod
def is_array(cls, element: entity_instance) -> bool:
return tool.Parametric.is_array(element)
class Array:
@classmethod
def bake_children_transform(cls, parent_element: ifcopenshell.entity_instance, item: int) -> None:
+6 -5
View File
@@ -147,17 +147,17 @@ class Parametric(bonsai.core.tool.Parametric):
self._data.clear()
self._gen = None
# FIXME(PR4): array / pipe_segment / duct_segment land with their
# finish/cancel operators in PR4. Adding them to EDIT_TYPES without those
# operators makes auto-commit-on-save dispatch bim.finish_editing_<name>
# for objects flagged as in-edit, which then raises because the operator
# doesn't exist. PR4 re-adds the three entries together with the operators.
# FIXME(PR5): pipe_segment / duct_segment land with their finish/cancel
# operators in the MEP slice of PR5 (PR5d). Until then they stay out of
# EDIT_TYPES so auto-commit-on-save doesn't try to dispatch a
# non-existent operator.
EDIT_TYPES: list[ParametricObject] = [
ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("railing", supports_build_edit_lifecycle=True),
ParametricObject("roof", supports_build_edit_lifecycle=True),
ParametricObject("array", supports_build_edit_lifecycle=True),
ParametricObject("wall"),
]
@@ -169,6 +169,7 @@ class Parametric(bonsai.core.tool.Parametric):
STAIR: ClassVar[ParametricObject]
RAILING: ClassVar[ParametricObject]
ROOF: ClassVar[ParametricObject]
ARRAY: ClassVar[ParametricObject]
WALL: ClassVar[ParametricObject]
_geom_generation: int = 0