From c7d5d6c498982dcf3e8729afddb13cd1804c84be Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Fri, 12 Jun 2026 11:05:02 +0200 Subject: [PATCH] Gate slab disconnect gizmos behind parametric edit lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires slabs into the parametric edit framework (tool.Parametric .EDIT_TYPES) so the wall-slab disconnect UI gets ESC handling, red cancel icon, mutual exclusion with other parametric edits, and per-feature gizmo prefs — all from BaseParametricGizmoGroup — without duplicating the lifecycle. Adds: - ParametricObject("slab") registry entry + tool.Parametric.is_slab predicate (any IfcSlab). - BIMSlabProperties with is_editing flag; PointerProperty wired by the framework's register_object_properties. - bim.enable_editing_slab / bim.finish_editing_slab / bim.cancel_editing_slab operators on tool.Ifc.Operator so they flow through tool.Parametric.run_bim_op cleanly. No IFC mutation — slab edit is a pure UI gate; finish and cancel share the body. - tool.Model.get_slab_props accessor. - GizmoSlabEdition inheriting BaseParametricGizmoGroup with the pen / validate / cancel triad. is_element_type narrows to IfcSlab with at least one wall clipped to its underside. The disconnect-icon group GizmoSlabUnjoinWalls polls behind _slab_connection_gizmo_poll_gate(require_editing=True), which now reads is_editing through tool.Model.get_slab_props. Drops the standalone GizmoSlabConnectionAccess + the setup_pen_cancel_icons helper added earlier in this branch — both superseded by the framework integration. Also folds in the wall + multi-slab gizmo polish requested live: - Wall side: stack the per-slab unjoin icons vertically (up to 5) so multi-slab connections each get a distinct clickable icon; hover-highlight reveals which slab will disconnect. - GizmoPairDisconnect activates when 2 elements with an IfcRelConnectsElements(TOP) rel are selected, with the icon at the wall-slab connection world anchor. - Wall-slab anchor moved from slab clip Z to wall top + WALL_SLAB_CONNECTION_Z_CLEARANCE so the disconnect icon perches above the extend-vertical / slope gizmo instead of overlapping. - Shared _resolve_active_partner_pair helper for 2-selection gizmos; _slab_connection_gizmo_poll_gate added to _REQUIRED_CALLEES + GizmoSlabEdition added to the AST forward-compat allowlist. Build note: wall.py's DisconnectElements._perform imports bonsai.core.connection.disconnect_rel — that core module is being added in a parallel-session commit. Until that lands the addon import will fail. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/__init__.py | 6 + src/bonsai/bonsai/bim/module/model/prop.py | 15 + src/bonsai/bonsai/bim/module/model/slab.py | 71 ++++ src/bonsai/bonsai/bim/module/model/wall.py | 349 +++++++++++++++--- src/bonsai/bonsai/tool/model.py | 5 + src/bonsai/bonsai/tool/parametric.py | 11 + src/bonsai/bonsai/tool/wall.py | 29 +- ..._wall_array_child_filter_forward_compat.py | 8 +- .../model/test_wall_slab_connections.py | 32 +- 9 files changed, 452 insertions(+), 74 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 4028520634..6583144c89 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -110,6 +110,8 @@ classes = ( wall.GizmoWallFilletPreview, wall.GizmoWallFilletReedit, wall.GizmoWallFilletToggleOpenings, + wall.GizmoSlabEdition, + wall.GizmoSlabUnjoinWalls, wall.GizmoWallJoinIntersection, wall.GizmoWallLinkToggle, wall.GizmoWallUnjoinSingle, @@ -154,11 +156,14 @@ classes = ( slab.DisableEditingExtrusionProfile, slab.DisableEditingSketchExtrusionProfile, slab.AddSlabFromWall, + slab.CancelEditingSlab, slab.DrawPolylineSlab, slab.EditExtrusionProfile, slab.EditSketchExtrusionProfile, slab.EnableEditingExtrusionProfile, slab.EnableEditingSketchExtrusionProfile, + slab.EnableEditingSlab, + slab.FinishEditingSlab, slab.RecalculateSlab, slab.ResetVertex, slab.SetArcIndex, @@ -185,6 +190,7 @@ classes = ( prop.BIMDoorProperties, prop.BIMRailingProperties, prop.BIMRoofProperties, + prop.BIMSlabProperties, prop.BIMWallProperties, prop.BIMPipeSegmentProperties, prop.BIMDuctSegmentProperties, diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index ebafafda6d..e2f8a2a9a8 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -1689,6 +1689,21 @@ class BIMRoofProperties(PropertyGroup): setattr(target_props, prop_name, prop_value) +class BIMSlabProperties(PropertyGroup): + """Transient state for the slab disconnect-access gizmo. + + ``is_editing`` flips True when the user clicks the pen icon on a slab + that has wall connections — gating the per-wall disconnect icons in + ``GizmoSlabUnjoinWalls`` so they're hidden until the user opts in. No + IFC draft state lives here: the disconnect operator commits directly, + so this PropertyGroup carries only the UI gate.""" + + is_editing: bpy.props.BoolProperty(name="Slab Edit Active", default=False, options={"SKIP_SAVE"}) + + if TYPE_CHECKING: + is_editing: bool + + class BIMWallProperties(PropertyGroup): """Transient draft state for parametric wall gizmo editing. diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 58a353ab28..516dd04233 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -991,3 +991,74 @@ class RecalculateSlab(bpy.types.Operator, tool.Ifc.Operator): tool.Model.recalculate_walls(walls) return {"FINISHED"} + + +class EnableEditingSlab(bpy.types.Operator, tool.Ifc.Operator): + """Open the slab disconnect-access mode. Pure UI toggle: flips + ``obj.BIMSlabProperties.is_editing`` so the per-wall disconnect + gizmos surface on the slab. ``tool.Ifc.Operator`` base because the + parametric framework's universal dispatcher routes through + ``tool.Parametric.run_bim_op``, which only accepts that subclass for + undo-safe lifecycle. No IFC mutation.""" + + bl_idname = "bim.enable_editing_slab" + bl_label = "Edit Slab Connections" + bl_description = "Show disconnect icons for every wall clipped to this slab" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if obj is None: + return False + element = tool.Ifc.get_entity(obj) + return element is not None and element.is_a("IfcSlab") + + def _execute(self, context): + context.active_object.BIMSlabProperties.is_editing = True + return {"FINISHED"} + + +class CancelEditingSlab(bpy.types.Operator, tool.Ifc.Operator): + """Close the slab disconnect-access mode.""" + + bl_idname = "bim.cancel_editing_slab" + bl_label = "Close Slab Edit" + bl_description = "Hide the slab disconnect icons" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if obj is None: + return False + element = tool.Ifc.get_entity(obj) + return element is not None and element.is_a("IfcSlab") + + def _execute(self, context): + context.active_object.BIMSlabProperties.is_editing = False + return {"FINISHED"} + + +class FinishEditingSlab(bpy.types.Operator, tool.Ifc.Operator): + """Close the slab disconnect-access mode. Same body as Cancel — slab + edit is a pure UI gate with no IFC draft to commit; the framework + requires both ``bim.finish_editing_`` and + ``bim.cancel_editing_`` to exist by name convention.""" + + bl_idname = "bim.finish_editing_slab" + bl_label = "Finish Slab Edit" + bl_description = "Hide the slab disconnect icons" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if obj is None: + return False + element = tool.Ifc.get_entity(obj) + return element is not None and element.is_a("IfcSlab") + + def _execute(self, context): + context.active_object.BIMSlabProperties.is_editing = False + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 1058de7151..33443b8fdd 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -48,6 +48,7 @@ import mathutils.geometry import numpy as np from mathutils import Matrix, Vector +import bonsai.core.connection import bonsai.core.geometry import bonsai.core.model as core import bonsai.core.root @@ -108,6 +109,50 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool: return True +def _resolve_active_partner_pair( + context: bpy.types.Context, +) -> "tuple[bpy.types.Object, bpy.types.Object, ifcopenshell.entity_instance, ifcopenshell.entity_instance] | None": + """Return ``(active_obj, partner_obj, active_elem, partner_elem)`` for a + selection of exactly two IFC-bound objects with the active one named, + else ``None``. Used by every 2-selection gizmo to skip the standard + "resolve active + partner + IFC entities" preamble.""" + active = tool.Blender.get_active_object(is_selected=True) + if active is None: + return None + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + return None + partner = next((o for o in selected if o != active), None) + if partner is None: + return None + active_elem = tool.Ifc.get_entity(active) + partner_elem = tool.Ifc.get_entity(partner) + if active_elem is None or partner_elem is None: + return None + return active, partner, active_elem, partner_elem + + +def _slab_connection_gizmo_poll_gate(context: bpy.types.Context, *, require_editing: bool = False) -> bool: + """Shared gate for slab-side connection gizmos: exactly 1 IfcSlab + selected, not an array child, has at least one wall clipped to its + underside. With ``require_editing=True`` additionally requires the + slab's parametric edit lifecycle to be active (pen icon clicked) so + the gizmo only surfaces after explicit opt-in.""" + active = tool.Blender.get_active_object(is_selected=True) + if active is None: + return False + if len(tool.Blender.get_selected_objects()) != 1: + return False + element = tool.Ifc.get_entity(active) + if element is None or not element.is_a("IfcSlab"): + return False + if tool.Blender.Modifier.any_selected_is_array_child(): + return False + if require_editing and not tool.Model.get_slab_props(active).is_editing: + return False + return any(True for _ in tool.Wall.iter_slab_wall_connections(element)) + + def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool: """Tighter gate for wall topology gizmos (merge / join / extend / unjoin / fillet): base ``_wall_gizmo_poll_gate`` plus an array-child filter. @@ -332,31 +377,27 @@ class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.I if not rels: self.report({"ERROR"}, "No connection found between elements.") return - # All rels between a single pair should share a kind in practice; pick - # the first kind for the cleanup dispatch and remove every rel below. - kind = rels[0][1] - if kind == "path": - for rel, _ in rels: - bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) - obj_a = tool.Ifc.get_object(elem_a) - obj_b = tool.Ifc.get_object(elem_b) - if obj_a is not None and obj_b is not None: - tool.Model.recreate_wall(elem_a, obj_a) - tool.Model.recreate_wall(elem_b, obj_b) - _resync_walls_after_mutation([obj_a, obj_b]) - elif kind in ("element-top", "element"): - for rel, _ in rels: - wall, slab = tool.Connection.orient_element_top(rel, elem_a, elem_b) - ifcopenshell.api.geometry.disconnect_element( - ifc_file, relating_element=slab, related_element=wall - ) - if kind == "element-top": - # The TOP rel is what extend_walls_to_underside creates; the - # related side is always the wall. - wall = rels[0][0].RelatedElement - wall_obj = tool.Ifc.get_object(wall) - if wall_obj is not None: - core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [wall_obj]) + path_objs: list[bpy.types.Object] = [] + for rel, kind in rels: + bonsai.core.connection.disconnect_rel( + tool.Ifc, + tool.Geometry, + tool.Model, + tool.Connection, + rel=rel, + kind=kind, + elem=elem_a, + partner=elem_b, + ) + if kind == "path": + obj_a = tool.Ifc.get_object(elem_a) + obj_b = tool.Ifc.get_object(elem_b) + if obj_a is not None and obj_a not in path_objs: + path_objs.append(obj_a) + if obj_b is not None and obj_b not in path_objs: + path_objs.append(obj_b) + if path_objs: + _resync_walls_after_mutation(path_objs) class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): @@ -3865,16 +3906,17 @@ class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo): class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): """Activates when exactly one LAYER2 wall is selected. Surfaces an unjoin icon at - every join location inferred from the wall's IfcRelConnectsPathElements inverse - graph — the single-selection mirror of `GizmoWallJoinIntersection`'s two-wall - unjoin state. A wall may participate in many such rels (up to 1 ATSTART + 1 ATEND - by end, plus unlimited ATPATH T-junctions), so a pool of icons is preallocated - and hidden on a per-frame basis based on the live connection set. + every connection location on the wall — wall-wall path connections via + IfcRelConnectsPathElements + wall-slab underside clips via IfcRelConnectsElements + with Description=="TOP". A wall may participate in many such rels (up to 1 ATSTART + + 1 ATEND by end, plus unlimited ATPATH T-junctions, plus one rel per clipped + slab), so a pool of icons is preallocated and hidden on a per-frame basis based + on the live connection set. Each visible icon dispatches `bim.disconnect_elements` with the active wall + - partner wall GlobalIds set on the bound operator properties, so a click removes - only the single rel under that icon — the other connections on the same wall - survive. + partner element GlobalIds set on the bound operator properties, so a click + removes only the single rel under that icon — the other connections on the + same wall survive. Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group requires len(selected) == 2; this one requires 1).""" @@ -3892,6 +3934,8 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix # creation is forbidden — so the pool must be sized upfront for the worst case. POOL_SIZE = 16 ICON_SCALE = 0.35 + SLAB_STACK_MAX = 5 + SLAB_STACK_OFFSET_Z = 0.5 @classmethod def poll(cls, context: bpy.types.Context) -> bool: @@ -3947,15 +3991,27 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix billboard_rot = gizmo.get_billboard_rotation(context) clearance = gizmo.top_down_clearance(context, billboard_rot) - connections = _get_wall_connections_cached(self, elem) - if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False): + path_connections = _get_wall_connections_cached(self, elem) + slab_connections = list(tool.Wall.iter_wall_slab_connections(elem)) + slab_overflow = max(0, len(slab_connections) - self.SLAB_STACK_MAX) + if slab_overflow and not getattr(self, "_slab_cap_warned", False): print( - f"[bonsai] GizmoWallUnjoinSingle: wall has {len(connections)} path connections; " + f"[bonsai] GizmoWallUnjoinSingle: wall has {len(slab_connections)} slab " + f"connections; only the first {self.SLAB_STACK_MAX} are shown stacked." + ) + self._slab_cap_warned = True + slab_connections = slab_connections[: self.SLAB_STACK_MAX] + total = len(path_connections) + len(slab_connections) + if total > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False): + print( + f"[bonsai] GizmoWallUnjoinSingle: wall has {total} connections " + f"({len(path_connections)} path + {len(slab_connections)} slab); " f"only the first {self.POOL_SIZE} unjoin gizmos are shown." ) self._pool_cap_warned = True - for slot_idx, (other_elem, self_ct, other_ct) in enumerate(connections): + slot_idx = 0 + for other_elem, self_ct, other_ct in path_connections: if slot_idx >= self.POOL_SIZE: break other_obj = tool.Ifc.get_object(other_elem) @@ -3966,21 +4022,216 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix continue seg_other = _wall_axis_world_segment_from_geom(other_obj, other_geom) location = tool.Wall.path_connection_location_world(seg_self, self_ct, seg_other, other_ct) + self._bind_unjoin_icon(slot_idx, location + clearance, billboard_rot, elem, other_elem, other_obj) + slot_idx += 1 + + for stack_idx, (slab_elem, _rel) in enumerate(slab_connections): + if slot_idx >= self.POOL_SIZE: + break + slab_obj = tool.Ifc.get_object(slab_elem) + if slab_obj is None: + continue + location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) + if location is None: + continue + # Stack vertically so each slab gets a distinct clickable icon; + # hover-highlight then shows the user which slab they're about to + # disconnect from. + stacked = location + Vector((0.0, 0.0, stack_idx * self.SLAB_STACK_OFFSET_Z)) + self._bind_unjoin_icon(slot_idx, stacked + clearance, billboard_rot, elem, slab_elem, slab_obj) + slot_idx += 1 + + def _bind_unjoin_icon(self, slot_idx, location, billboard_rot, active_elem, partner_elem, partner_obj): + """Place + bind one pool icon to a (active, partner) GlobalId pair. + + Only the GlobalId properties are rewritten per frame; the operator + binding itself is the long-lived handle set up at setup() time. GlobalId + (not Blender object name) keeps the binding stable across renames, file + save/reload, and any sit-in-the-undo-stack interlude between dispatch + and execute. The partner Blender object is mirrored onto the icon for + its hover-outline draw, since the Gizmo API exposes + ``target_set_operator`` but no symmetric reader.""" + icon = self.unjoin_icons[slot_idx] + icon.matrix_basis = gizmo.billboarded_at(location, billboard_rot, scale=self.ICON_SCALE) + icon.hide = False + self.unjoin_op_props[slot_idx].element_a_guid = active_elem.GlobalId + self.unjoin_op_props[slot_idx].element_b_guid = partner_elem.GlobalId + icon.partner_obj = partner_obj + + +class GizmoSlabUnjoinWalls(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): + """Slab-side mirror of GizmoWallUnjoinSingle: when exactly one IfcSlab is + selected and at least one wall is clipped to its underside, surface an + unjoin icon at each connection point. The icons resolve at the same + world location as the wall-side gizmo (via the symmetric + tool.Wall.wall_slab_connection_location_world) so the same connection + has a single visual marker reachable from either selection. + + Each visible icon dispatches bim.disconnect_elements with the slab + + wall GlobalIds, so a click removes the single rel under that icon and + re-clips the wall to whatever remaining slabs it's connected to.""" + + bl_idname = "OBJECT_GGT_bim_slab_unjoin_walls" + bl_label = "Slab Unjoin Walls Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + POOL_SIZE = 16 + ICON_SCALE = 0.35 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + return _slab_connection_gizmo_poll_gate(context, require_editing=True) + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_decoration_colors() + self.unjoin_icons = [] + self.unjoin_op_props = [] + for _ in range(self.POOL_SIZE): + icon = self.setup_icon_gizmo( + "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements" + ) + icon.hide = True + self.unjoin_icons.append(icon) + self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements")) + + def position_gizmos(self, context: bpy.types.Context) -> None: + for icon in self.unjoin_icons: + icon.hide = True + + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + return + slab_obj = selected[0] + slab_elem = tool.Ifc.get_entity(slab_obj) + if slab_elem is None: + return + + billboard_rot = gizmo.get_billboard_rotation(context) + clearance = gizmo.top_down_clearance(context, billboard_rot) + connections = list(tool.Wall.iter_slab_wall_connections(slab_elem)) + if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False): + print( + f"[bonsai] GizmoSlabUnjoinWalls: slab has {len(connections)} wall connections; " + f"only the first {self.POOL_SIZE} unjoin gizmos are shown." + ) + self._pool_cap_warned = True + + slot_idx = 0 + for wall_elem, _rel in connections: + if slot_idx >= self.POOL_SIZE: + break + wall_obj = tool.Ifc.get_object(wall_elem) + if wall_obj is None: + continue + location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) + if location is None: + continue icon = self.unjoin_icons[slot_idx] icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE) icon.hide = False - # Only the GlobalId properties are rewritten per frame; the operator - # binding itself is the long-lived handle set up at setup() time. GlobalId - # (not Blender object name) keeps the binding stable across renames, file - # save/reload, and any sit-in-the-undo-stack interlude between dispatch - # and execute. - self.unjoin_op_props[slot_idx].element_a_guid = elem.GlobalId - self.unjoin_op_props[slot_idx].element_b_guid = other_elem.GlobalId - # Mirror the partner reference onto the icon itself so its draw() - # can outline the partner on hover without a Gizmo-side getter on - # the bound operator (the API exposes target_set_operator with - # no symmetric reader). - icon.partner_obj = other_obj + self.unjoin_op_props[slot_idx].element_a_guid = slab_elem.GlobalId + self.unjoin_op_props[slot_idx].element_b_guid = wall_elem.GlobalId + icon.partner_obj = wall_obj + slot_idx += 1 + + +class GizmoSlabEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): + """Pen / validate / cancel triad for slab disconnect-access mode. + + Polls on a single IfcSlab with at least one wall clipped to its underside. + Pen routes through the universal ``bim.enable_editing_parametric`` + dispatcher; finish + cancel both clear ``is_editing`` (no IFC mutation — + the framework requires the triad to exist by name convention even for a + pure UI gate). ESC, the red-coloured cancel icon, mutual exclusion with + other active parametric edits, gizmo prefs gating — all handled by the + base class.""" + + bl_idname = "OBJECT_GGT_bim_slab_edition" + bl_label = "Slab Editing Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + enable_editing_operator = "bim.enable_editing_slab" + finish_editing_operator = "bim.finish_editing_slab" + cancel_editing_operator = "bim.cancel_editing_slab" + cycle_type_operator = "" + + props_getter = tool.Model.get_slab_props + gizmo_pref_name = "slab" + + @classmethod + def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool: + return tool.Parametric.is_slab(element) and any( + True for _ in tool.Wall.iter_slab_wall_connections(element) + ) + + +class GizmoPairDisconnect(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): + """Surfaces a disconnect icon when exactly 2 IFC elements are selected + and they share a supported rel — currently the wall + slab pair joined + by an ``IfcRelConnectsElements(TOP)``. Click dispatches + ``bim.disconnect_elements`` with both GlobalIds. For wall-wall pairs, + ``GizmoWallJoinIntersection``'s unjoin icon already exposes the same + affordance via ``bim.unjoin_walls``.""" + + bl_idname = "OBJECT_GGT_bim_pair_disconnect" + bl_label = "Disconnect Pair Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + ICON_SCALE = 0.35 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + return False + if tool.Blender.Modifier.any_selected_is_array_child(): + return False + elem_a = tool.Ifc.get_entity(selected[0]) + elem_b = tool.Ifc.get_entity(selected[1]) + if elem_a is None or elem_b is None: + return False + rels = tool.Connection.find_rels(elem_a, elem_b) + return any(kind == "element-top" for _, kind in rels) + + def setup(self, context: bpy.types.Context) -> None: + default_color, highlight_color = self.get_decoration_colors() + self.disconnect_icon = self.setup_icon_gizmo( + "VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements" + ) + self.disconnect_icon.hide = True + self.disconnect_op = self.disconnect_icon.target_set_operator("bim.disconnect_elements") + + def position_gizmos(self, context: bpy.types.Context) -> None: + self.disconnect_icon.hide = True + pair = _resolve_active_partner_pair(context) + if pair is None: + return + active, partner_obj, active_elem, partner_elem = pair + # Helper expects wall + slab regardless of which the user marked active. + if active_elem.is_a("IfcWall"): + wall_obj, slab_obj = active, partner_obj + elif partner_elem.is_a("IfcWall"): + wall_obj, slab_obj = partner_obj, active + else: + return + location = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) + if location is None: + return + billboard_rot = gizmo.get_billboard_rotation(context) + clearance = gizmo.top_down_clearance(context, billboard_rot) + self.disconnect_icon.matrix_basis = gizmo.billboarded_at( + location + clearance, billboard_rot, scale=self.ICON_SCALE + ) + self.disconnect_icon.hide = False + self.disconnect_op.element_a_guid = active_elem.GlobalId + self.disconnect_op.element_b_guid = partner_elem.GlobalId + self.disconnect_icon.partner_obj = partner_obj class GizmoWallFilletPreview(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 2ea1678479..d126657e36 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -82,6 +82,7 @@ if TYPE_CHECKING: BIMPolylineProperties, BIMRailingProperties, BIMRoofProperties, + BIMSlabProperties, BIMStairProperties, BIMSverchokProperties, BIMWallProperties, @@ -118,6 +119,10 @@ class Model(bonsai.core.tool.Model): def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties: return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod + def get_slab_props(cls, obj: bpy.types.Object) -> BIMSlabProperties: + return obj.BIMSlabProperties # pyright: ignore[reportAttributeAccessIssue] + @classmethod def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties: return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue] diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py index 3c3fba66f5..313133f192 100644 --- a/src/bonsai/bonsai/tool/parametric.py +++ b/src/bonsai/bonsai/tool/parametric.py @@ -157,6 +157,7 @@ class Parametric(bonsai.core.tool.Parametric): ParametricObject("pipe_segment", supports_build_edit_lifecycle=True), ParametricObject("duct_segment", supports_build_edit_lifecycle=True), ParametricObject("wall"), + ParametricObject("slab"), ] # Annotations for the uppercase constants populated from ``EDIT_TYPES`` by @@ -171,6 +172,7 @@ class Parametric(bonsai.core.tool.Parametric): PIPE_SEGMENT: ClassVar[ParametricObject] DUCT_SEGMENT: ClassVar[ParametricObject] WALL: ClassVar[ParametricObject] + SLAB: ClassVar[ParametricObject] _geom_generation: int = 0 @@ -459,6 +461,15 @@ class Parametric(bonsai.core.tool.Parametric): return False return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None + @classmethod + def is_slab(cls, element: entity_instance) -> bool: + """``True`` for any ``IfcSlab``. The slab edit lifecycle only gates + the connection-disconnect UI — no IFC mutation — so we don't narrow + further (e.g. by checking for wall connections). Per-gizmo polls + layer the "has wall connections" check on top via + ``tool.Wall.iter_slab_wall_connections``.""" + return element is not None and element.is_a("IfcSlab") + @classmethod def is_wall(cls, element: entity_instance) -> bool: """A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage. diff --git a/src/bonsai/bonsai/tool/wall.py b/src/bonsai/bonsai/tool/wall.py index 6f471e0c8a..b3c850e790 100644 --- a/src/bonsai/bonsai/tool/wall.py +++ b/src/bonsai/bonsai/tool/wall.py @@ -281,22 +281,35 @@ class Wall(bonsai.core.tool.Wall): return rel return None + WALL_SLAB_CONNECTION_Z_CLEARANCE = 0.5 + """Lift above the wall top so the disconnect icon sits above the + extend-vertical / slope gizmo and reads as "the thing above the wall = + the slab connection".""" + @classmethod def wall_slab_connection_location_world( cls, wall_obj: bpy.types.Object, slab_obj: bpy.types.Object ) -> Vector | None: - """World-space point where a wall is clipped by a slab — the wall's - axis midpoint lifted to the slab's underside Z. Approximate: uses the - slab's mesh bbox bottom in world space rather than reconstructing the - slab's clip plane. Adequate for icon placement on a wall whose top - meets the slab; returns ``None`` when the wall has no reference line.""" + """World-space anchor for the wall-slab disconnect icon. + + X / Y come from the wall axis midpoint (so the icon sits in the + middle of the wall horizontally); Z is the wall's top in world space + plus ``WALL_SLAB_CONNECTION_Z_CLEARANCE`` so the icon perches above + the slope gizmo. The slab-side gizmo calls this with the same + arguments so both sides of the same connection render a single + visual marker. ``slab_obj`` is kept on the signature for the + symmetric call shape; the helper's body no longer reads from it. + Returns ``None`` when the wall has no reference line.""" ref = cls.get_world_reference_line(wall_obj) if ref is None: return None axis_mid_world = (ref[0] + ref[1]) * 0.5 - slab_bottom_local_z = min(c[2] for c in slab_obj.bound_box) - slab_bottom_world_z = (slab_obj.matrix_world @ Vector((0.0, 0.0, slab_bottom_local_z))).z - return Vector((axis_mid_world.x, axis_mid_world.y, slab_bottom_world_z)) + if wall_obj.bound_box: + wall_top_local_z = max(c[2] for c in wall_obj.bound_box) + wall_top_world_z = (wall_obj.matrix_world @ Vector((0.0, 0.0, wall_top_local_z))).z + else: + wall_top_world_z = axis_mid_world.z + return Vector((axis_mid_world.x, axis_mid_world.y, wall_top_world_z + cls.WALL_SLAB_CONNECTION_Z_CLEARANCE)) @classmethod def walk_connected_walls( diff --git a/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py b/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py index 6c1b367b7c..6e4a7fdca9 100644 --- a/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py +++ b/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py @@ -26,6 +26,8 @@ Allow-list (gizmos intentionally outside the rule): - ``GizmoWallEdition`` — single-object parametric edit gizmo. Its base parametric poll already filters array children. +- ``GizmoSlabEdition`` — same as ``GizmoWallEdition`` (inherits + ``BaseParametricGizmoGroup`` whose poll filters array children). - ``GizmoWallFilletPreview`` — the preview-owner whose poll must fire WHILE its own preview is active; routing it through the topology gate would self-block it. @@ -47,9 +49,11 @@ pytestmark = pytest.mark.model # Wall gizmo groups intentionally outside the rule. Add a new entry only # with the in-code reasoning above. -_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"}) +_ALLOWLIST = frozenset({"GizmoSlabEdition", "GizmoWallEdition", "GizmoWallFilletPreview"}) -_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"}) +_REQUIRED_CALLEES = frozenset( + {"_wall_topology_gizmo_poll_gate", "_slab_connection_gizmo_poll_gate", "any_selected_is_array_child"} +) def _wall_module_source(): diff --git a/src/bonsai/test/bim/module/model/test_wall_slab_connections.py b/src/bonsai/test/bim/module/model/test_wall_slab_connections.py index 5bd9704e3c..5fff484943 100644 --- a/src/bonsai/test/bim/module/model/test_wall_slab_connections.py +++ b/src/bonsai/test/bim/module/model/test_wall_slab_connections.py @@ -178,28 +178,30 @@ def test_find_wall_slab_rel_returns_none_when_unconnected(): # --------------------------------------------------------------------------- -def test_wall_slab_connection_location_lifts_axis_mid_to_slab_underside(): - """The icon sits at the wall's axis midpoint X/Y lifted to the slab's - underside Z so it reads as a marker on the slab cut line.""" +def test_wall_slab_connection_location_perches_above_wall_top(): + """Icon X/Y comes from the wall axis midpoint; Z from the wall's mesh + bbox top in world space plus WALL_SLAB_CONNECTION_Z_CLEARANCE so the + icon sits above the extend-vertical / slope gizmo at the wall top.""" wall_obj = Mock() - slab_obj = Mock() - slab_obj.matrix_world = Matrix.Translation(Vector((0.0, 0.0, 3.0))) - slab_obj.bound_box = [ - (-1.0, -1.0, 0.0), - (1.0, -1.0, 0.0), - (-1.0, 1.0, 0.0), - (1.0, 1.0, 0.0), - (-1.0, -1.0, 0.2), - (1.0, -1.0, 0.2), - (-1.0, 1.0, 0.2), - (1.0, 1.0, 0.2), + wall_obj.matrix_world = Matrix.Identity(4) + wall_obj.bound_box = [ + (-0.1, -0.1, 0.0), + (0.1, -0.1, 0.0), + (-0.1, 0.1, 0.0), + (0.1, 0.1, 0.0), + (-0.1, -0.1, 3.0), + (0.1, -0.1, 3.0), + (-0.1, 0.1, 3.0), + (0.1, 0.1, 3.0), ] + slab_obj = Mock() ref_line = (Vector((1.0, 0.0, 0.0)), Vector((3.0, 0.0, 0.0))) with patch.object(tool.Wall, "get_world_reference_line", return_value=ref_line): loc = tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) - assert loc == Vector((2.0, 0.0, 3.0)) + expected_z = 3.0 + tool.Wall.WALL_SLAB_CONNECTION_Z_CLEARANCE + assert loc == Vector((2.0, 0.0, expected_z)) def test_wall_slab_connection_location_returns_none_for_axisless_wall():