diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index ff916bf8a9..40d9ae6855 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -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() diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 071ede7ac3..f0482d24cd 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -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 diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 5e2ef55e5c..c60048a5db 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -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, diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index e4bfb8fedd..648fc760ce 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -17,22 +17,86 @@ # along with Bonsai. If not, see . import json +from typing import ClassVar import bpy +import ifcopenshell import ifcopenshell.api.pset import ifcopenshell.util.element import ifcopenshell.util.unit -from mathutils import Matrix +from mathutils import Matrix, Vector +import bonsai.bim.module.drawing.gizmos as gizmo import bonsai.tool as tool +from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig +from bonsai.bim.parametric_lifecycle import ParametricEditMixinBase + + +def _wipe_array_children(layers: list) -> None: + """Delete every existing array child and clear ``children`` GUID lists. + + Rebuilding mints fresh GlobalIds, so external references (BCF, IDS, etc.) + to the old GUIDs go stale. The bbox heuristic skips the wipe when the + parent's geometry is unchanged.""" + 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 not None: + tool.Geometry.delete_ifc_object(child_obj) + layer["children"] = [] + + +def _bbox_dims(bound_box) -> tuple[float, float, float]: + """Return ``(width, depth, height)`` of an ``obj.bound_box`` 8-corner tuple.""" + xs = [c[0] for c in bound_box] + ys = [c[1] for c in bound_box] + zs = [c[2] for c in bound_box] + return (max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs)) + + +_BBOX_EQUALITY_EPS = 1e-5 + + +def _parent_geometry_changed(parent_obj, layers: list) -> bool: + """Cheap heuristic: True when the parent's bbox differs from the first resolvable child's, + indicating a parametric edit since the last regen. Misses edits that preserve bbox dimensions + (e.g. shape changes within the same envelope); those need a manual "Regenerate Array".""" + if not parent_obj.bound_box: + return True + parent_dims = _bbox_dims(parent_obj.bound_box) + 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 not child_obj.bound_box: + continue + child_dims = _bbox_dims(child_obj.bound_box) + return any(abs(a - b) > _BBOX_EQUALITY_EPS for a, b in zip(parent_dims, child_dims)) + return False class AddArray(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_array" bl_label = "Add Array" - bl_description = "Add Bonsai parametric array to the active IFC element" + bl_description = "Add an array of the active object" bl_options = {"REGISTER", "UNDO"} + # Optional parameters — defaults preserve the existing UX (count=1, no offset) + # for the panel + script callers. The gizmo-driven path (see + # ``AddArrayFromFeatureEdit``) passes bbox-derived values so the new array + # has a visible second instance and interactable offset gizmos out of the box. + count: bpy.props.IntProperty(name="Count", default=1, min=1) + x: bpy.props.FloatProperty(name="X Offset", default=0.0) + y: bpy.props.FloatProperty(name="Y Offset", default=0.0) + z: bpy.props.FloatProperty(name="Z Offset", default=0.0) + def _execute(self, context): assert (obj := context.active_object) assert (element := tool.Ifc.get_entity(obj)) @@ -54,12 +118,13 @@ class AddArray(bpy.types.Operator, tool.Ifc.Operator): array = { "children": [], - "count": 1, - "x": 0.0, - "y": 0.0, - "z": 0.0, + "count": self.count, + "x": self.x, + "y": self.y, + "z": self.z, "use_local_space": True, "method": "OFFSET", + "per_child_opening": True, } pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") @@ -78,22 +143,35 @@ class AddArray(bpy.types.Operator, tool.Ifc.Operator): properties={"Parent": element.GlobalId, "Data": ifc_file.create_entity("IfcText", json.dumps(data))}, ) + # Always regenerate so callers passing count >= 2 see the second+ instance + # appear immediately. No-op for count=1 layers. + tool.Model.regenerate_array(obj, data) + tool.Array.constrain_children_to_parent(element) + class DisableEditingArray(bpy.types.Operator): bl_idname = "bim.disable_editing_array" bl_label = "Disable Editing Array" + bl_description = "Cancel editing this array without saving changes" bl_options = {"REGISTER", "UNDO"} def execute(self, context): obj = context.active_object assert obj - tool.Model.get_array_props(obj).is_editing = -1 + tool.Model.get_array_props(obj).editing_item_index = -1 return {"FINISHED"} -class EnableEditingArray(bpy.types.Operator): - bl_idname = "bim.enable_editing_array" - bl_label = "Enable Editing Array" +class EnableEditingArrayItem(bpy.types.Operator): + """Per-item array layer editing: hydrates props from one BBIM_Array layer. + + The element-wide ``bim.enable_editing_array`` (parametric edit lifecycle) coexists with + this operator. They target different state: ``is_editing`` for the edit lifecycle, + ``editing_item_index`` for the per-item panel UI.""" + + bl_idname = "bim.enable_editing_array_item" + bl_label = "Enable Editing Array Item" + bl_description = "Edit this array layer" bl_options = {"REGISTER", "UNDO"} item: bpy.props.IntProperty() @@ -119,14 +197,16 @@ class EnableEditingArray(bpy.types.Operator): props.z = data["z"] * si_conversion props.use_local_space = data.get("use_local_space", False) props.method = data.get("method", "OFFSET") + props.per_child_opening = data.get("per_child_opening", data.get("mirror_to_host", True)) - props.is_editing = self.item + props.editing_item_index = self.item return {"FINISHED"} class EditArray(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.edit_array" bl_label = "Edit Array" + bl_description = "Save changes to this array layer" bl_options = {"REGISTER", "UNDO"} item: bpy.props.IntProperty() @@ -146,9 +226,10 @@ class EditArray(bpy.types.Operator, tool.Ifc.Operator): "z": props.z / si_conversion, "use_local_space": props.use_local_space, "method": props.method, + "per_child_opening": props.per_child_opening, } - props.is_editing = -1 + props.editing_item_index = -1 try: parent_element = tool.Ifc.get().by_guid(pset["Parent"]) @@ -156,20 +237,225 @@ class EditArray(bpy.types.Operator, tool.Ifc.Operator): except: return {"FINISHED"} - tool.Blender.Modifier.Array.remove_constraints(parent_element) + tool.Array.remove_constraints(parent_element) + # Conditional wipe-and-rebuild — only when the parent's geometry + # differs from the children's. See ``_parent_geometry_changed`` for + # the bbox-dim heuristic and its known false-negative case. + if _parent_geometry_changed(parent, data): + _wipe_array_children(data) tool.Model.regenerate_array(parent, data) - tool.Blender.Modifier.Array.set_children_lock_state(element, self.item, True) - tool.Blender.Modifier.Array.constrain_children_to_parent(element) + tool.Array.set_children_lock_state(element, self.item, True) + tool.Array.constrain_children_to_parent(element) # clears the relating_array_object so it doesn't show again next time props.relating_array_object = None +class _ArrayEditMixin(ParametricEditMixinBase): + """Array edit lifecycle scoped to one layer at a time. + + ``is_editing`` is paired with ``editing_item_index`` so Finish/Cancel + know which layer to commit/discard. Gizmo drag mutates props in place; + IFC writes happen only at Finish.""" + + pset_name = "BBIM_Array" + + @classmethod + def _is_element_type(cls, element): + return tool.Parametric.is_array(element) + + @classmethod + def _get_props(cls, obj: bpy.types.Object): + return tool.Model.get_array_props(obj) + + @classmethod + def _iter_targets(cls, context: bpy.types.Context) -> list[bpy.types.Object]: + obj = context.active_object + return [obj] if obj else [] + + @classmethod + def _resolve(cls, obj: bpy.types.Object): + element = tool.Ifc.get_entity(obj) + if not element or not cls._is_element_type(element): + return None + return element, cls._get_props(obj) + + @classmethod + def _read_layers(cls, element) -> list: + return json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data") or "[]") + + @classmethod + def _hydrate_props_from_layer(cls, props, layer: dict, si_conversion: float) -> None: + props.count = layer["count"] + props.x = layer["x"] * si_conversion + props.y = layer["y"] * si_conversion + props.z = layer["z"] * si_conversion + props.use_local_space = layer.get("use_local_space", True) + props.method = layer.get("method", "OFFSET") + props.per_child_opening = layer.get("per_child_opening", layer.get("mirror_to_host", True)) + + @classmethod + def _set_children_visibility(cls, element, hidden: bool) -> None: + """Hide array children during edit so only the preview ghosts show. + Auto-commit unhides on save; load-time heal clears stale flags.""" + layers = cls._read_layers(element) + 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 not None: + child_obj.hide_set(hidden) + + @classmethod + def _enable_one(cls, obj: bpy.types.Object, item: int = 0) -> None: + """Enter array editing for layer ``item``; no-op for out-of-range index. + + If ``props.relating_array_object`` points at another array parent, + seed the draft props from that object's matching layer.""" + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + cls._handle_drift_on_enable(obj) + source_layers = cls._layers_from_relating(props) or cls._read_layers(element) + if item < 0 or item >= len(source_layers): + return + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + cls._hydrate_props_from_layer(props, source_layers[item], si_conversion) + props.is_editing = True + props.editing_item_index = item + cls._set_children_visibility(element, hidden=True) + + @classmethod + def _layers_from_relating(cls, props) -> list | None: + """If ``props.relating_array_object`` is set and resolves to another + array parent, return its layers; else ``None``.""" + relating = getattr(props, "relating_array_object", None) + if relating is None: + return None + element = tool.Ifc.get_entity(relating) + if element is None: + return None + parent_guid = ifcopenshell.util.element.get_pset(element, "BBIM_Array", "Parent") + if not parent_guid: + 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: + return json.loads(data_text) + except (ValueError, TypeError): + return None + + @classmethod + def _finish_one(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None: + """Commit the in-progress edit to ``editing_item_index``'s layer. + Drift (layer removed mid-edit) clears the flag and aborts.""" + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + layers = cls._read_layers(element) + item = props.editing_item_index + if item < 0 or item >= len(layers): + props.is_editing = False + props.editing_item_index = -1 + return + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + layers[item]["count"] = props.count + layers[item]["x"] = props.x / si_conversion + layers[item]["y"] = props.y / si_conversion + layers[item]["z"] = props.z / si_conversion + layers[item]["use_local_space"] = props.use_local_space + layers[item]["method"] = props.method + layers[item]["per_child_opening"] = props.per_child_opening + # Note: ``tool.Model.regenerate_array`` below removes and re-adds the + # BBIM_Array pset with the in-memory ``layers`` data ([tool/model.py: + # 1163-1167](src/bonsai/bonsai/tool/model.py#L1163-L1167)), so an + # explicit ``edit_pset`` call here would just be overwritten — and + # each redundant call adds an entry to the IFC owner-history audit + # trail. Rely on the regenerator's pset write instead. + tool.Array.remove_constraints(element) + # Wipe-and-rebuild only when the parent's geometry differs from the + # children's (cheap bbox-dim compare). For pure count / offset edits + # the children are already valid and ``regenerate_array``'s in-place + # transform updates are enough — saves the delete + re-duplicate cost + # per instance on large arrays. + if _parent_geometry_changed(obj, layers): + _wipe_array_children(layers) + tool.Model.regenerate_array(obj, layers) + tool.Array.set_children_lock_state(element, item, True) + tool.Array.constrain_children_to_parent(element) + # Set only on success: if any IFC op above raised, the draft survives for retry. + props.is_editing = False + props.editing_item_index = -1 + props.relating_array_object = None + # Unhide the (possibly newly-regenerated) children so the user sees + # the committed result. Mirrors the hide in ``_enable_one``. + cls._set_children_visibility(element, hidden=False) + + @classmethod + def _cancel_one(cls, obj: bpy.types.Object) -> None: + resolved = cls._resolve(obj) + if resolved is None: + return + element, props = resolved + layers = cls._read_layers(element) + item = props.editing_item_index + if 0 <= item < len(layers): + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + cls._hydrate_props_from_layer(props, layers[item], si_conversion) + props.is_editing = False + props.editing_item_index = -1 + # Restore visibility — the pset is unchanged from when we hid them, so + # the children's committed positions are still where they were before. + cls._set_children_visibility(element, hidden=False) + + def _enable_targets(self, context: bpy.types.Context, item: int = 0) -> set[str]: + for obj in self._iter_targets(context): + self._enable_one(obj, item=item) + return {"FINISHED"} + + def _finish_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._finish_one(obj, context) + return {"FINISHED"} + + def _cancel_targets(self, context: bpy.types.Context) -> set[str]: + for obj in self._iter_targets(context): + self._cancel_one(obj) + return {"FINISHED"} + + +EnableEditingArray, FinishEditingArray, CancelEditingArray = tool.Parametric.build_edit_lifecycle( + "array", + _ArrayEditMixin, + labels=( + ( + "Enable Editing Array", + "Edit this array — drag the offset arrows, adjust the count, switch the spacing method", + ), + ("Finish Editing Array", "Save the array changes and rebuild the copies"), + ("Cancel Editing Array", "Discard the array changes and leave the existing copies as they were"), + ), + enable_extra_props={"item": bpy.props.IntProperty(name="Layer Index", default=0, min=0)}, + enable_extra_kwargs=lambda self: {"item": self.item}, + module_name=__name__, +) + + class ApplyArray(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.apply_array" bl_label = "Apply Array" bl_options = {"REGISTER", "UNDO"} - bl_description = "Apply the array and keep children as separate entities. Only available for the last array" + bl_description = "Convert the array's copies into independent objects (last layer only)" def _execute(self, context): obj = context.active_object @@ -183,17 +469,26 @@ class ApplyArray(bpy.types.Operator, tool.Ifc.Operator): class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.regenerate_array" bl_label = "Regenerate Array" + bl_description = "Rebuild the array's copies from the original (works on parent or any copy)" bl_options = {"REGISTER", "UNDO"} def _execute(self, context): obj = context.active_object element = tool.Ifc.get_entity(obj) pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset or "Parent" not in pset: + self.report({"ERROR"}, "Active object is not part of a Bonsai parametric array.") + return {"CANCELLED"} try: parent_element = tool.Ifc.get().by_guid(pset["Parent"]) - parent = tool.Ifc.get_object(parent_element) - except: - return {"FINISHED"} + except RuntimeError: + self.report( + {"ERROR"}, + f"Array parent GlobalId {pset['Parent']!r} not found — the array's parent " + "element was deleted externally. Reseat the array by re-creating it.", + ) + return {"CANCELLED"} + parent = tool.Ifc.get_object(parent_element) pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") arrays = json.loads(pset["Data"]) pset = tool.Ifc.get().by_id(pset["id"]) @@ -202,14 +497,21 @@ class RegenerateArray(bpy.types.Operator, tool.Ifc.Operator): if child_obj := tool.Ifc.get_object(tool.Ifc.get().by_guid(child)): tool.Geometry.delete_ifc_object(child_obj) array["children"].clear() - print("cleared array", arrays) - tool.Model.regenerate_array(obj, arrays) - tool.Blender.Modifier.Array.constrain_children_to_parent(element) + # Always operate on the parent — this operator can be invoked with + # either the parent OR any array child as active_object (the per-child + # gizmo group fires it from a child selection). Using ``obj`` / + # ``element`` directly would feed a child to ``regenerate_array`` and + # constrain children against a sibling, silently corrupting the array. + tool.Model.regenerate_array(parent, arrays) + tool.Array.constrain_children_to_parent(parent_element) class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_array" bl_label = "Remove Array" + bl_description = ( + "Remove this array layer (enable 'Keep Objects' to keep its copies as independent objects — last layer only)" + ) bl_options = {"REGISTER", "UNDO"} item: bpy.props.IntProperty() keep_objs: bpy.props.BoolProperty(name="Keep Objects", default=False) @@ -228,7 +530,7 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): ) return {"FINISHED"} - props.is_editing = -1 + props.editing_item_index = -1 try: parent_element = tool.Ifc.get().by_guid(pset["Parent"]) @@ -237,12 +539,12 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} if self.keep_objs: - tool.Blender.Modifier.Array.bake_children_transform(element, self.item) - tool.Blender.Modifier.Array.set_children_lock_state(element, self.item, False) + tool.Array.bake_children_transform(element, self.item) + tool.Array.set_children_lock_state(element, self.item, False) if not self.keep_objs: data[self.item]["count"] = 1 - tool.Blender.Modifier.Array.remove_constraints(parent_element) + tool.Array.remove_constraints(parent_element) tool.Model.regenerate_array(parent, data, array_layers_to_apply=[self.item] if self.keep_objs else []) pset = tool.Pset.get_element_pset(element, "BBIM_Array") @@ -252,12 +554,13 @@ class RemoveArray(bpy.types.Operator, tool.Ifc.Operator): del data[self.item] data = tool.Ifc.get().createIfcText(json.dumps(data)) ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data}) - tool.Blender.Modifier.Array.constrain_children_to_parent(element) + tool.Array.constrain_children_to_parent(element) class SelectArrayParent(bpy.types.Operator): bl_idname = "bim.select_array_parent" bl_label = "Select Array Parent" + bl_description = "Select the original object that this array copy belongs to" bl_options = {"REGISTER", "UNDO"} @classmethod @@ -290,6 +593,7 @@ class SelectArrayParent(bpy.types.Operator): class SelectAllArrayObjects(bpy.types.Operator): bl_idname = "bim.select_all_array_objects" bl_label = "Select All Array Objects" + bl_description = "Select the original object and all of its array copies" bl_options = {"REGISTER", "UNDO"} @classmethod @@ -320,7 +624,7 @@ class SelectAllArrayObjects(bpy.types.Operator): self.report({"ERROR"}, f"Objects that don't have an array parent, were deselected.") object.select_set(False) - array_objects = tool.Blender.Modifier.Array.get_all_objects(parent_element) + array_objects = tool.Array.get_all_objects(parent_element) tool.Blender.set_objects_selection( context, active_object=array_objects[0], @@ -330,9 +634,128 @@ class SelectAllArrayObjects(bpy.types.Operator): return {"FINISHED"} +class ArrayParentGizmoClick(bpy.types.Operator): + """Dispatcher for the parent-tree gizmo on array children. + + - Click: select the array's parent. + - Shift+Click: select parent + all children. + - Ctrl+Click: select all children, excluding the parent.""" + + bl_idname = "bim.array_parent_gizmo_click" + bl_label = "Select Array Parent / Family" + bl_description = ( + "Click: select the original object.\n" + "Shift+Click: select the original object and all of its copies.\n" + "Ctrl+Click: select only the copies" + ) + bl_options = {"REGISTER", "UNDO"} + + mode: bpy.props.EnumProperty( + name="Mode", + items=[ + ("PARENT", "Parent", "Select the array parent only"), + ("ALL", "All", "Select parent + every child"), + ("CHILDREN", "Children", "Select every child, excluding the parent"), + ], + default="PARENT", + ) + + @classmethod + def poll(cls, context): + if not context.active_object: + cls.poll_message_set("No active object selected") + return False + return True + + def invoke(self, context, event): + if event.shift: + self.mode = "ALL" + elif event.ctrl: + self.mode = "CHILDREN" + else: + self.mode = "PARENT" + return self.execute(context) + + def execute(self, context): + if self.mode == "PARENT": + return bpy.ops.bim.select_array_parent("EXEC_DEFAULT") + if self.mode == "ALL": + return bpy.ops.bim.select_all_array_objects("EXEC_DEFAULT") + # CHILDREN: resolve the parent of the active child, then select every + # child of that parent's array without the parent itself. + obj = context.active_object + element = tool.Ifc.get_entity(obj) + if element is None: + self.report({"ERROR"}, "Active object is not IFC-linked.") + return {"CANCELLED"} + array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not array_pset: + self.report({"ERROR"}, "Object is not part of an array.") + return {"CANCELLED"} + try: + parent_element = tool.Ifc.get().by_guid(array_pset["Parent"]) + except RuntimeError: + self.report({"ERROR"}, f"Couldn't find array parent by guid '{array_pset['Parent']}'") + return {"CANCELLED"} + all_objects = tool.Array.get_all_objects(parent_element) + parent_obj = tool.Ifc.get_object(parent_element) + children = [o for o in all_objects if o is not parent_obj] + if not children: + self.report({"INFO"}, "Array has no children to select.") + return {"FINISHED"} + tool.Blender.set_objects_selection( + context, + active_object=children[0], + selected_objects=children, + clear_previous_selection=True, + ) + return {"FINISHED"} + + +class EditArrayFromChild(bpy.types.Operator): + bl_idname = "bim.edit_array_from_child" + bl_label = "Edit Array From Child" + bl_description = "Edit the array this copy belongs to" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if not obj: + cls.poll_message_set("No active object selected") + return False + return True + + def execute(self, context): + obj = context.active_object + element = tool.Ifc.get_entity(obj) + if element is None: + self.report({"ERROR"}, "Active object is not IFC-linked.") + return {"CANCELLED"} + array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not array_pset: + self.report({"ERROR"}, "Object is not part of an array.") + return {"CANCELLED"} + try: + parent_element = tool.Ifc.get().by_guid(array_pset["Parent"]) + except RuntimeError: + self.report({"ERROR"}, f"Couldn't find array parent by guid '{array_pset['Parent']}'") + return {"CANCELLED"} + parent_obj = tool.Ifc.get_object(parent_element) + if not parent_obj: + self.report({"ERROR"}, "Array parent has no Blender object.") + return {"CANCELLED"} + layer_index = tool.Array.get_child_layer_index(element) + if layer_index is None: + layer_index = 0 + tool.Blender.select_and_activate_single_object(context, active_object=parent_obj) + return bpy.ops.bim.enable_editing_array("INVOKE_DEFAULT", item=layer_index) + + class Input3DCursorXArray(bpy.types.Operator): bl_idname = "bim.input_cursor_x_array" bl_label = "Get 3d Cursor X Input for Array" + bl_description = "Set the X offset from the 3D cursor position" bl_options = {"REGISTER", "UNDO"} def execute(self, context): @@ -350,6 +773,7 @@ class Input3DCursorXArray(bpy.types.Operator): class Input3DCursorYArray(bpy.types.Operator): bl_idname = "bim.input_cursor_y_array" bl_label = "Get 3d Cursor Y Input for Array" + bl_description = "Set the Y offset from the 3D cursor position" bl_options = {"REGISTER", "UNDO"} def execute(self, context): @@ -367,6 +791,7 @@ class Input3DCursorYArray(bpy.types.Operator): class Input3DCursorZArray(bpy.types.Operator): bl_idname = "bim.input_cursor_z_array" bl_label = "Get 3d Cursor Z Input for Array" + bl_description = "Set the Z offset from the 3D cursor position" bl_options = {"REGISTER", "UNDO"} def execute(self, context): @@ -381,35 +806,6 @@ class Input3DCursorZArray(bpy.types.Operator): return {"FINISHED"} -class EnableEditingParametric(bpy.types.Operator): - """Pen-icon dispatcher: fires the gizmo group's per-feature edit operator. - - Bound to every parametric gizmo group's pen icon. The gizmo group's own - ``enable_editing_operator`` (``bim.enable_editing_door``, ``…_wall``, …) - is passed as ``feature_enable_op`` at setup time and invoked here. The - indirection lets one gizmo class serve all features without per-feature - subclasses.""" - - bl_idname = "bim.enable_editing_parametric" - bl_label = "Enable Editing" - bl_description = "Edit this object's parameters" - bl_options = {"REGISTER", "UNDO"} - - feature_enable_op: bpy.props.StringProperty( - default="", - description="Operator bl_idname to invoke (e.g., 'bim.enable_editing_door').", - ) - - def execute(self, context): - # Malformed ``feature_enable_op`` (missing dot) would otherwise crash - # the unpack with ValueError; treat the same as the empty-string case. - parts = self.feature_enable_op.split(".", 1) - if len(parts) != 2: - return {"CANCELLED"} - domain, opname = parts - return getattr(getattr(bpy.ops, domain), opname)("INVOKE_DEFAULT") - - class AddArrayFromFeatureEdit(bpy.types.Operator, tool.Ifc.Operator): """Commit any in-progress feature edit and add an array with gizmo-friendly defaults (count=2, offset = bbox extent along the axis). @@ -505,3 +901,683 @@ class AddArrayFromFeatureEdit(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} bpy.ops.bim.enable_editing_array("INVOKE_DEFAULT", item=len(layers) - 1) return {"FINISHED"} + + +class ArrayGizmoClick(bpy.types.Operator): + """Per-layer ARRAY gizmo dispatcher: click enters edit for that layer, + Shift+click adds a new layer with bbox-derived defaults. + + Wired to each layer icon in ``GizmoArrayEdition``'s row-of-arrays. The + ``item`` property identifies which layer the user clicked, so the same + operator class is reused across all surfaced layer gizmos.""" + + bl_idname = "bim.array_gizmo_click" + bl_label = "Array Layer" + bl_description = "Click: edit this array layer.\n" "Shift+Click: add another array layer" + bl_options = {"REGISTER", "UNDO"} + + item: bpy.props.IntProperty(name="Layer Index", default=0, min=0) + + def invoke(self, context, event): + if event.shift: + # Pass ``axis="X"`` via EXEC_DEFAULT to bypass + # ``AddArrayFromFeatureEdit.invoke``'s modifier read — Shift on + # the layer-indicator gizmo already means "add new layer", so + # we shouldn't reinterpret it as "axis = Y" downstream. + return bpy.ops.bim.add_array_from_feature_edit("EXEC_DEFAULT", axis="X") + return bpy.ops.bim.enable_editing_array("INVOKE_DEFAULT", item=self.item) + + def execute(self, context): + # No event in scripting / keymap exec contexts — plain enter-edit fallback. + return bpy.ops.bim.enable_editing_array("EXEC_DEFAULT", item=self.item) + + +class EnableEditingParametric(bpy.types.Operator): + """Pen-icon dispatcher: fires the gizmo group's per-feature edit operator. + + Bound to every parametric gizmo group's pen icon. The gizmo group's own + ``enable_editing_operator`` (``bim.enable_editing_door``, ``…_wall``, …) + is passed as ``feature_enable_op`` at setup time and invoked here. The + indirection lets one gizmo class serve all features without per-feature + subclasses. + + Array editing has its own dedicated entry points (the per-layer ARRAY + gizmo icons and the panel's pen button) — this dispatcher does not + branch into array edit anymore.""" + + bl_idname = "bim.enable_editing_parametric" + bl_label = "Enable Editing" + bl_description = "Edit this object's parameters" + bl_options = {"REGISTER", "UNDO"} + + feature_enable_op: bpy.props.StringProperty( + default="", + description="Operator bl_idname to invoke (e.g., 'bim.enable_editing_door').", + ) + + def execute(self, context): + # Malformed ``feature_enable_op`` (missing dot) would otherwise crash + # the unpack with ValueError; treat the same as the empty-string case. + parts = self.feature_enable_op.split(".", 1) + if len(parts) != 2: + return {"CANCELLED"} + domain, opname = parts + return getattr(getattr(bpy.ops, domain), opname)("INVOKE_DEFAULT") + + +class ToggleArrayMethod(bpy.types.Operator): + """Cycle the array layer's ``method`` between OFFSET and DISTRIBUTE. + + OFFSET: each instance is placed at ``i * (x, y, z)`` from the parent — + spacing is fixed, total span scales with count. + + DISTRIBUTE: instances are spread evenly between the parent and the offset + endpoint — total span is fixed at ``(x, y, z)``, spacing scales with count. + + No-op outside an active edit lifecycle so the operator can't bypass the Finish + commit lifecycle by quietly flipping the method during a non-editing state.""" + + bl_idname = "bim.toggle_array_method" + bl_label = "Toggle Array Method" + bl_description = "Switch between fixed spacing between copies and fixed total span" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Model.get_array_props(obj) + if not props.is_editing: + return {"CANCELLED"} + props.method = "DISTRIBUTE" if props.method == "OFFSET" else "OFFSET" + return {"FINISHED"} + + +class RemoveArrayLayerFromEdit(bpy.types.Operator, tool.Ifc.Operator): + """Discard the in-progress edit and delete the array layer being edited. + + Bound to the trash gizmo at the far right of the edit row. Reads the + currently-edited layer from ``props.editing_item_index``, cancels the + edit lifecycle (unhides children, clears editing flags), then routes through + ``bim.remove_array`` to delete the layer from the BBIM_Array pset. + Existing children of that layer are deleted as part of remove_array. + + Inherits ``tool.Ifc.Operator`` so the two chained sub-operators + (``bim.cancel_editing_array`` + ``bim.remove_array``) run inside one + transaction — one undo step instead of two, and an atomic rollback if + the second op fails (no torn state where the draft is gone but the + layer remains). The nested ``tool.Ifc.Operator`` calls detect the + existing top-level transaction (via ``IfcStore.current_transaction`` + in [bim/ifc.py:486](src/bonsai/bonsai/bim/ifc.py#L486)) and join it + rather than opening their own.""" + + bl_idname = "bim.remove_array_layer_from_edit" + bl_label = "Remove Array Layer" + bl_description = "Discard the in-progress edit and delete this array layer" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if not obj: + cls.poll_message_set("No active object selected") + return False + props = tool.Model.get_array_props(obj) + if not props.is_editing: + return False + # Mirror ``_execute``'s precondition so the gizmo correctly + # disables on stale states (is_editing flag set but the index + # was cleared by drift, undo, or external mutation). + return props.editing_item_index >= 0 + + def _execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Model.get_array_props(obj) + item = props.editing_item_index + if item < 0: + return {"CANCELLED"} + # Cancel the in-progress edit first — this unhides children and + # clears is_editing / editing_item_index. Then remove the layer + # (which deletes its children and the layer's BBIM_Array entry). + # Both calls join this operator's transaction (see class docstring). + bpy.ops.bim.cancel_editing_array("EXEC_DEFAULT") + bpy.ops.bim.remove_array("EXEC_DEFAULT", item=item) + return {"FINISHED"} + + +class InputArrayCount(bpy.types.Operator): + """Open a number-input dialog so the user can type a new ``count`` during + an active edit lifecycle. Bound to the world-space count gizmo in the edit + row (between cancel and minus) — for users who'd rather type a value than + repeatedly click +/-.""" + + bl_idname = "bim.input_array_count" + bl_label = "Set Array Count" + bl_description = "Type the number of copies for this array" + bl_options = {"REGISTER", "UNDO"} + + count: bpy.props.IntProperty(name="Count", default=1, min=1) + + def invoke(self, context, event): + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Model.get_array_props(obj) + if not props.is_editing: + return {"CANCELLED"} + self.count = max(1, props.count) + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Model.get_array_props(obj) + if not props.is_editing: + return {"CANCELLED"} + props.count = max(1, self.count) + return {"FINISHED"} + + +class AdjustArrayCount(bpy.types.Operator): + """Bump props.count by ``increment`` during an active element-wide edit lifecycle. + + Bound to the +/- icon gizmos flanking the count drag handle. No-ops outside + an edit lifecycle so accidentally invoking it doesn't bypass the commit lifecycle — + Finish writes IFC; this operator only touches the draft props.""" + + bl_idname = "bim.adjust_array_count" + bl_label = "Adjust Array Count" + bl_description = "Add or remove a copy from the array" + bl_options = {"REGISTER", "UNDO"} + increment: bpy.props.IntProperty() + + def execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + props = tool.Model.get_array_props(obj) + if not props.is_editing: + return {"CANCELLED"} + props.count = max(1, props.count + self.increment) + return {"FINISHED"} + + +class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): + """Viewport gizmos for the array edit lifecycle (single-layer arrays only). + Drag/+/- mutate draft props in place; commit happens at Finish.""" + + bl_idname = "OBJECT_GGT_bim_array_edition" + bl_label = "Array Editing Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + enable_editing_operator = "bim.enable_editing_array" + finish_editing_operator = "bim.finish_editing_array" + cancel_editing_operator = "bim.cancel_editing_array" + cycle_type_operator = "" + # Array is not itself arrayable from the gizmo entry — adding an array + # layer to an existing array is the panel's "+" button job. Hides the + # base-class ARRAY icon for this gizmo group only. + hide_array_button = True + # The clickable ``xN`` count label (``GizmoArrayCount``) is already the + # entry point for array edit mode from the idle state — see this class's + # docstring. The default pen icon emitted by ``BaseParametricGizmoGroup`` + # is redundant with it, and on a feature object that's ALSO array-parent + # (e.g. an arrayed duct segment) the user sees two pen icons: one for the + # feature edit, one next to the array count. Hiding this group's pen + # collapses to a single per-feature pen. + hide_pen_button = True + + # Local-X positions of the editing-row extras (count label + adjuster icons). + # Layout left-to-right at the same Y/Z as validate (ICON_VALIDATE_X = 0.0) and + # cancel (ICON_VALIDATE_X + ICON_CANCEL_X = 0.5): + # validate | cancel | xN | - | + | method-toggle | trash. + # Spacing mirrors stair's editing-row constants so the icons match in visual rhythm. + # Trash sits past the method toggle with a slightly wider gap so the destructive + # action stays visually separated from the routine edit controls. + ICON_NUMBER_X = 0.87 + ICON_MINUS_X = 1.24 + ICON_PLUS_X = 1.61 + ICON_METHOD_X = 1.98 + ICON_DELETE_X = 2.55 + # Render scale for the +/- and method-toggle icons — ~70% of the standard + # 0.5 used for validate/cancel. Makes the helpers look secondary. + ICON_HELPER_SCALE = 0.35 + + # Per-layer ARRAY icons — one shown in idle state per existing array + # layer, surfaced to the right of the pen. Pre-allocated at setup time + # (Blender's gizmo API doesn't support creating gizmos on demand at draw + # time); the cap keeps the GPU resource footprint bounded. Multi-layer + # arrays with more than ``MAX_LAYER_GIZMOS`` layers fall back to the + # per-item panel UX for the overflow layers. + MAX_LAYER_GIZMOS = 8 + # Local-X spacing between successive layer icons. The start position + # of the first icon is feature-aware (see ``_resolve_feature_idle_max_x``) + # so it doesn't collide with feature-specific idle gizmos (e.g. wall's + # toggle-openings + offset-baseline icons that share the row). + LAYER_GIZMO_SPACING = 0.4 + + # Per-feature idle-state rightmost icon X. Layer icons start past this + # so they don't collide with feature-specific idle gizmos. Centralised + # here (rather than declared per-feature) because the array group is + # the consumer and this knowledge is local to its layout decision. + # Door / window / stair / roof / railing have no idle icons past the + # pen, so they default to 0.0. + _FEATURE_IDLE_MAX_X: ClassVar[dict[str, float]] = { + "wall": 0.87, # past offset_baseline (EXT/CEN/INT share the cycle slot) + } + + dimension_gizmo_props = [ + # matrix_position must be provided even at the origin: without it, the + # base class falls back to ``Matrix.Identity(4)`` for ``base_matrix``, + # which means ``matrix_basis.col[0]`` (what GizmoDimension.draw reads + # for the line direction) becomes the object's local X — every offset + # gizmo would then render along X regardless of ``config.axis``. + # ``compose_gizmo_matrix(position, axis)`` rotates so col[0] aligns with + # the requested axis. The non-zero offsets along the OTHER two axes + # shift each gizmo's origin off the object centre so they don't pile + # up at (0,0,0). + # Each offset gizmo starts at the centre of the bounding-box face + # perpendicular to its axis (e.g. X-offset anchors at the +X face + # centre). This pulls the three arrows apart visually and makes the + # arrow tip land exactly where the next instance would appear — a + # natural read of "drag this face out by N metres to space siblings". + DimensionGizmoConfig( + attr_name="x", + axis=(1, 0, 0), + prop_name="X Offset", + min_value=-1e6, + matrix_position=lambda p: GizmoArrayEdition._axis_start(0), + ), + DimensionGizmoConfig( + attr_name="y", + axis=(0, 1, 0), + prop_name="Y Offset", + min_value=-1e6, + matrix_position=lambda p: GizmoArrayEdition._axis_start(1), + ), + DimensionGizmoConfig( + attr_name="z", + axis=(0, 0, 1), + prop_name="Z Offset", + min_value=-1e6, + matrix_position=lambda p: GizmoArrayEdition._axis_start(2), + ), + ] + + props_getter = tool.Model.get_array_props + gizmo_pref_name = "array" + + @staticmethod + def _axis_start(axis_index: int) -> Vector: + """Local-space anchor for the offset gizmo on the given axis: the + centre of the bounding-box face perpendicular to that axis on its + positive side. + + Axis 0 (X) → centre of the +X face; + Axis 1 (Y) → centre of the +Y face; + Axis 2 (Z) → centre of the +Z face. + + Returns ``Vector((0, 0, 0))`` when ``bpy.context.active_object`` is + unset or has no ``bound_box`` attribute. For 0-extent objects (Empties, + point annotations) the bbox is 8 zero-corners and the math yields the + origin naturally — same result, different path.""" + obj = bpy.context.active_object + if obj is None or not obj.bound_box: + return Vector((0.0, 0.0, 0.0)) + xs = [c[0] for c in obj.bound_box] + ys = [c[1] for c in obj.bound_box] + zs = [c[2] for c in obj.bound_box] + center_x = (min(xs) + max(xs)) / 2 + center_y = (min(ys) + max(ys)) / 2 + center_z = (min(zs) + max(zs)) / 2 + if axis_index == 0: + return Vector((max(xs), center_y, center_z)) + if axis_index == 1: + return Vector((center_x, max(ys), center_z)) + return Vector((center_x, center_y, max(zs))) + + @classmethod + def is_element_type(cls, element: "ifcopenshell.entity_instance") -> bool: + """Poll for any array parent — multi-layer is fully supported now via + the per-layer ARRAY icons. The edit lifecycle reads ``editing_item_index`` to + pick the target layer.""" + return tool.Parametric.is_array(element) + + def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: + """Create the +/- count adjusters, the method toggle, and the + per-layer ARRAY entry icons (one per existing array layer).""" + self.count_plus_gizmo = self.create_icon_gizmo( + "VIEW3D_GT_plus", self.COLOR_GREEN, "bim.adjust_array_count", increment=1 + ) + self.count_minus_gizmo = self.create_icon_gizmo( + "VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_array_count", increment=-1 + ) + # Method toggle uses the cycle icon (circular arrow) — same affordance + # the stair / roof type-cycle gizmos use, signalling "click to swap". + default_color, highlight_color = self.get_decoration_colors() + self.method_gizmo = self.create_icon_gizmo("VIEW3D_GT_cycle", default_color, "bim.toggle_array_method") + # World-space count display for the edit row. Click opens a numeric + # input dialog (``bim.input_array_count``) so the user can type a + # value directly instead of clicking +/- repeatedly. Renders the same + # ``xN`` glyph as the idle-state per-layer icons for visual consistency. + self.count_label_gizmo = self.gizmos.new("BIM_GT_array_layer_indicator") + self.count_label_gizmo.use_draw_scale = False + self.count_label_gizmo.color = default_color + self.count_label_gizmo.color_highlight = highlight_color + self.count_label_gizmo.alpha = 0.8 + self.count_label_gizmo.target_set_operator("bim.input_array_count") + # Destructive delete button at the far right of the edit row — red + # like the minus gizmo so the user reads "destructive" before the + # tooltip even appears. The dispatcher cancels the in-progress edit + # first (clearing edit state + unhiding children) then removes the + # layer in one click. + self.delete_gizmo = self.create_icon_gizmo( + "VIEW3D_GT_trash", self.COLOR_RED, "bim.remove_array_layer_from_edit" + ) + + # Per-layer ARRAY icons — pre-allocated up to ``MAX_LAYER_GIZMOS`` and + # shown/hidden in ``_refresh_element_specific`` based on the actual + # layer count. ``BIM_GT_array_layer_indicator`` renders the 2×2-grid + # glyph PLUS an ``xN`` count label above it (one custom gizmo per + # layer keeps the label co-located with the icon at all zoom levels). + # Each binds to ``bim.array_gizmo_click(item=i)``; the dispatcher + # routes plain clicks to ``enable_editing_array(item=i)`` and + # Shift+click to ``add_array_from_feature_edit`` (new layer). + self.layer_gizmos = [] + for i in range(self.MAX_LAYER_GIZMOS): + gz = self.gizmos.new("BIM_GT_array_layer_indicator") + gz.use_draw_scale = False + gz.color = default_color + gz.color_highlight = highlight_color + gz.alpha = 0.8 + op = gz.target_set_operator("bim.array_gizmo_click") + op.item = i + # Bind layer index for the hover-publisher inside the gizmo's own + # draw method (see ``GizmoArrayLayerIndicator._publish_hover``). + gz.set_layer_index(i) + self.layer_gizmos.append(gz) + + def get_icon_y_extent(self, props) -> tuple[float, float]: + """Return the active object's bounding-box Y extents (positive, + negative absolute distances from origin) plus the standard 2× icon + clearance — same pattern feature gizmo groups use for their pen-icon + Y position. Without this, the array layer icons sit at the object + centerline while the feature pen sits at the camera-facing edge, and + the two rows end up on different local Y planes.""" + obj = bpy.context.active_object + if obj is None or not obj.bound_box: + return (0.0, 0.0) + ys = [c[1] for c in obj.bound_box] + pad = 2 * self.GIZMO_OFFSET + return (max(0.0, max(ys)) + pad, max(0.0, -min(ys)) + pad) + + def get_element_height(self, props) -> float: + """Return the visual top of the active object (bounding-box Z max) so the + array validate/cancel icons sit at the same world position as the + feature gizmo group's pen icon would on the same element. + + Why not the base behaviour: the base reads ``props.overall_height`` / + ``props.height``, but ``BIMArrayProperties`` has neither — the array layer + list doesn't know how tall the underlying door / wall / … actually is. + Using the bounding-box top is a generic proxy that works for every + arrayable IFC type, parametric or otherwise.""" + obj = bpy.context.active_object + if obj and obj.bound_box: + return max(corner[2] for corner in obj.bound_box) + return 1.0 + + def update_editing_gizmos(self, context: bpy.types.Context, mw: Matrix, props) -> None: + """Suppress the array gizmo group's pen when a per-feature pen is already showing. + + Parametric arrayed elements (a door array, a wall array, …) get TWO pen icons + without this — the per-feature one and the array one. The per-feature pen + is the entry point for that feature's parametric edit; the per-layer ARRAY + icons (drawn alongside it) are the entry point for array edit. The array + group's own pen is redundant here and gets hidden. Non-parametric arrays + (IfcAnnotation / Opening / SpatialElement) have no per-feature group, so + the array pen stays visible there as the only entry point.""" + gizmo.BaseParametricGizmoGroup.update_editing_gizmos(self, context, mw, props) + if not props.is_editing: + obj = context.active_object + element = tool.Ifc.get_entity(obj) if obj else None + if element and self._has_other_parametric_type(element): + self.pen_gizmo.hide = True + + @staticmethod + def _has_other_parametric_type(element) -> bool: + match = tool.Parametric.find_for_element(element) + return match is not None and match.name != "array" + + def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props) -> None: + """Position the editing-row extras and the per-layer ARRAY icons. + + Idle (``not props.is_editing``): show one ARRAY icon per existing + array layer to the right of the pen. The +/-, method, and editing-row + icons stay hidden. + + Active edit: show the editing row (validate, cancel, − / +, method); + hide the per-layer icons so they don't clutter the edit UX.""" + icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET + icon_y = self.get_icon_y_offset(context, mw) + billboard_rot = self._frame_billboard_rot + + if not props.is_editing: + # Idle: show one ARRAY icon per existing layer. Per-edit helpers off. + self.count_plus_gizmo.hide = True + self.count_minus_gizmo.hide = True + self.method_gizmo.hide = True + self.count_label_gizmo.hide = True + self.delete_gizmo.hide = True + layers = self._read_array_layers(context) + layer_count = min(len(layers), self.MAX_LAYER_GIZMOS) + # Feature-aware start X — pushes the layer icons past any + # feature-specific idle gizmos (e.g. wall's toggle-openings + + # offset-baseline) so they don't stack on top. + start_x = self.ICON_VALIDATE_X + self._resolve_feature_idle_max_x(context) + self.ICON_ARRAY_GAP + for i, gz in enumerate(self.layer_gizmos): + if i >= layer_count: + gz.hide = True + continue + if self.is_gizmo_hidden_by_modal(gz): + gz.hide = True + continue + gz.hide = False + # Per-layer count rendered as ``xN`` above the gizmo glyph. + gz.set_count(int(layers[i].get("count", 0))) + world_pos = mw @ Vector( + ( + start_x + i * self.LAYER_GIZMO_SPACING, + icon_y, + icon_z, + ) + ) + gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, scale=0.5) + return + + # Active edit: hide the layer icons, show the editing row. The + # layer-hover bbox lives inside each layer gizmo's draw method, so + # hiding the gizmos is enough to stop the hover highlight too. + for gz in self.layer_gizmos: + gz.hide = True + # Same Y/Z as the validate/cancel icons set by the base + # ``update_editing_gizmos`` — they form one horizontal row. Helper + # icons (+/-, method) use ``ICON_HELPER_SCALE`` so they look secondary. + for gizmo_name, local_x in ( + ("count_minus_gizmo", self.ICON_VALIDATE_X + self.ICON_MINUS_X), + ("count_plus_gizmo", self.ICON_VALIDATE_X + self.ICON_PLUS_X), + ("method_gizmo", self.ICON_VALIDATE_X + self.ICON_METHOD_X), + ("delete_gizmo", self.ICON_VALIDATE_X + self.ICON_DELETE_X), + ): + gizmo_obj = getattr(self, gizmo_name) + if self.is_gizmo_hidden_by_modal(gizmo_obj): + gizmo_obj.hide = True + continue + gizmo_obj.hide = False + self.set_icon_gizmo_position( + gizmo_name, + mw=mw, + x=local_x, + y=icon_y, + z=icon_z, + billboard_rot=billboard_rot, + scale=self.ICON_HELPER_SCALE, + ) + # Clickable world-space ``xN`` between cancel and minus. Mirrors the + # draft ``props.count`` so the displayed value tracks +/- drags live. + if self.is_gizmo_hidden_by_modal(self.count_label_gizmo): + self.count_label_gizmo.hide = True + else: + self.count_label_gizmo.hide = False + self.count_label_gizmo.set_count(int(props.count)) + world_pos = mw @ Vector( + ( + self.ICON_VALIDATE_X + self.ICON_NUMBER_X, + icon_y, + icon_z, + ) + ) + self.count_label_gizmo.matrix_basis = gizmo.billboarded_at( + world_pos, billboard_rot, scale=self.ICON_HELPER_SCALE + ) + + @staticmethod + def _read_array_layers(context: bpy.types.Context) -> list: + """Return the active object's BBIM_Array layer list (one dict per + layer), or an empty list if not resolvable. Used to decide how many + per-layer ARRAY icons to surface AND to read each layer's ``count`` + for the ``xN`` label rendered by ``GizmoArrayLayerIndicator``.""" + obj = context.active_object + if obj is None: + return [] + element = tool.Ifc.get_entity(obj) + if element is None: + return [] + data_text = ifcopenshell.util.element.get_pset(element, "BBIM_Array", "Data") + if not data_text: + return [] + try: + return json.loads(data_text) + except (ValueError, TypeError): + return [] + + @classmethod + def _resolve_feature_idle_max_x(cls, context: bpy.types.Context) -> float: + """Return the rightmost local-X used by the active element's matching + per-feature gizmo group in idle state. Per-layer ARRAY icons start + one ``ICON_ARRAY_GAP`` past this so they don't stack on top of any + feature-specific idle icons. + + Resolves the active element's type via ``tool.Parametric.find_for_element`` + (registry lookup, see [tool/parametric.py:321]) and indexes into the + local ``_FEATURE_IDLE_MAX_X`` table. Defaults to 0.0 when: + - no active object + - object isn't an IFC element + - element doesn't match any registry type + - matched type is ``"array"`` (no per-feature gizmo group to dodge) + - matched type has no entry in the table""" + obj = context.active_object + if obj is None: + return 0.0 + element = tool.Ifc.get_entity(obj) + if element is None: + return 0.0 + match = tool.Parametric.find_for_element(element) + if match is None or match.name == "array": + return 0.0 + return cls._FEATURE_IDLE_MAX_X.get(match.name, 0.0) + + +class GizmoArrayChild(bpy.types.GizmoGroup): + """Three helper icons surfaced on each array child, mirroring the panel + actions for that array — Regenerate, Select Parent, Select All Array Objects. + + Standalone gizmo group (not a ``BaseParametricGizmoGroup`` subclass) because + the base's ``poll`` early-returns on array children (the mutual-exclusion + safeguard for the per-feature gizmo groups) and none of the editing-lifecycle + scaffolding applies — there's nothing to edit on a managed replica. + + Two navigation icons: + - ``VIEW3D_GT_array_parent`` (hierarchy tree) → modifier-aware select via + ``bim.array_parent_gizmo_click``: click selects the parent, Shift+click + selects the whole family, Ctrl+click selects only the children. + - ``VIEW3D_GT_array_all`` (2×2 grid) → jump to the parent and enter + array edit (mirrors the per-layer ARRAY icon on the parent, so the + child has a one-click path into the same edit flow). + + Regenerate isn't surfaced here — it's a maintenance action the panel + still exposes, and adding it as a child gizmo just clutters the viewport + without giving anything the panel doesn't.""" + + bl_idname = "OBJECT_GGT_bim_array_child" + bl_label = "Array Child Helpers" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + # Local-X positions for the two icons above the child's bounding-box top. + # 0.5 spacing keeps the hit regions clear at standard view distances. + ICON_PARENT_X = 0.0 + ICON_ALL_X = 0.5 + ICON_Z_OFFSET = 0.5 + ICON_SCALE = 0.5 + ICON_ALPHA = 0.8 + + @classmethod + def poll(cls, context): + obj = tool.Blender.get_active_object(is_selected=True) + if obj is None: + return False + if not tool.Blender.are_viewport_gizmos_enabled(): + return False + if len(tool.Blender.get_selected_objects()) != 1: + return False + element = tool.Ifc.get_entity(obj) + if not element: + return False + return tool.Blender.Modifier.is_array_child(element) + + def setup(self, context: bpy.types.Context) -> None: + prefs = tool.Blender.get_addon_preferences() + default_color = prefs.decorator_color_unselected[:3] + highlight_color = prefs.decorator_color_selected[:3] + self.parent_gizmo = self._make_icon( + "VIEW3D_GT_array_parent", default_color, highlight_color, "bim.array_parent_gizmo_click" + ) + self.all_gizmo = self._make_icon( + "VIEW3D_GT_array_all", default_color, highlight_color, "bim.edit_array_from_child" + ) + + def _make_icon( + self, + gizmo_type: str, + color: tuple[float, float, float], + highlight_color: tuple[float, float, float], + operator: str, + ) -> bpy.types.Gizmo: + gz = self.gizmos.new(gizmo_type) + gz.use_draw_scale = False + gz.color = color + gz.color_highlight = highlight_color + gz.alpha = self.ICON_ALPHA + gz.target_set_operator(operator) + return gz + + def draw_prepare(self, context: bpy.types.Context) -> None: + obj = context.active_object + if obj is None or not obj.bound_box: + return + bbox_top = max(corner[2] for corner in obj.bound_box) + billboard_rot = gizmo.get_billboard_rotation(context) + mw = obj.matrix_world + for name, x in ( + ("parent_gizmo", self.ICON_PARENT_X), + ("all_gizmo", self.ICON_ALL_X), + ): + gz = getattr(self, name) + world_pos = mw @ Vector((x, 0, bbox_top + self.ICON_Z_OFFSET)) + gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, self.ICON_SCALE) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index c2cdab4512..f45cd9b135 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -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 diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index 633715bef6..f2777cf743 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -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] diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index c94ec38d72..75afc55f7b 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -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: diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py index 47a1097bdc..c807a123ce 100644 --- a/src/bonsai/bonsai/tool/parametric.py +++ b/src/bonsai/bonsai/tool/parametric.py @@ -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_ - # 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