Gate slab disconnect gizmos behind parametric edit lifecycle

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.
This commit is contained in:
Gorgious56
2026-06-12 11:05:02 +02:00
parent a3593ed58b
commit c7d5d6c498
9 changed files with 452 additions and 74 deletions
@@ -110,6 +110,8 @@ classes = (
wall.GizmoWallFilletPreview, wall.GizmoWallFilletPreview,
wall.GizmoWallFilletReedit, wall.GizmoWallFilletReedit,
wall.GizmoWallFilletToggleOpenings, wall.GizmoWallFilletToggleOpenings,
wall.GizmoSlabEdition,
wall.GizmoSlabUnjoinWalls,
wall.GizmoWallJoinIntersection, wall.GizmoWallJoinIntersection,
wall.GizmoWallLinkToggle, wall.GizmoWallLinkToggle,
wall.GizmoWallUnjoinSingle, wall.GizmoWallUnjoinSingle,
@@ -154,11 +156,14 @@ classes = (
slab.DisableEditingExtrusionProfile, slab.DisableEditingExtrusionProfile,
slab.DisableEditingSketchExtrusionProfile, slab.DisableEditingSketchExtrusionProfile,
slab.AddSlabFromWall, slab.AddSlabFromWall,
slab.CancelEditingSlab,
slab.DrawPolylineSlab, slab.DrawPolylineSlab,
slab.EditExtrusionProfile, slab.EditExtrusionProfile,
slab.EditSketchExtrusionProfile, slab.EditSketchExtrusionProfile,
slab.EnableEditingExtrusionProfile, slab.EnableEditingExtrusionProfile,
slab.EnableEditingSketchExtrusionProfile, slab.EnableEditingSketchExtrusionProfile,
slab.EnableEditingSlab,
slab.FinishEditingSlab,
slab.RecalculateSlab, slab.RecalculateSlab,
slab.ResetVertex, slab.ResetVertex,
slab.SetArcIndex, slab.SetArcIndex,
@@ -185,6 +190,7 @@ classes = (
prop.BIMDoorProperties, prop.BIMDoorProperties,
prop.BIMRailingProperties, prop.BIMRailingProperties,
prop.BIMRoofProperties, prop.BIMRoofProperties,
prop.BIMSlabProperties,
prop.BIMWallProperties, prop.BIMWallProperties,
prop.BIMPipeSegmentProperties, prop.BIMPipeSegmentProperties,
prop.BIMDuctSegmentProperties, prop.BIMDuctSegmentProperties,
@@ -1689,6 +1689,21 @@ class BIMRoofProperties(PropertyGroup):
setattr(target_props, prop_name, prop_value) 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): class BIMWallProperties(PropertyGroup):
"""Transient draft state for parametric wall gizmo editing. """Transient draft state for parametric wall gizmo editing.
@@ -991,3 +991,74 @@ class RecalculateSlab(bpy.types.Operator, tool.Ifc.Operator):
tool.Model.recalculate_walls(walls) tool.Model.recalculate_walls(walls)
return {"FINISHED"} 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_<name>`` and
``bim.cancel_editing_<name>`` 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"}
+300 -49
View File
@@ -48,6 +48,7 @@ import mathutils.geometry
import numpy as np import numpy as np
from mathutils import Matrix, Vector from mathutils import Matrix, Vector
import bonsai.core.connection
import bonsai.core.geometry import bonsai.core.geometry
import bonsai.core.model as core import bonsai.core.model as core
import bonsai.core.root import bonsai.core.root
@@ -108,6 +109,50 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
return True 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: def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool:
"""Tighter gate for wall topology gizmos (merge / join / extend / unjoin """Tighter gate for wall topology gizmos (merge / join / extend / unjoin
/ fillet): base ``_wall_gizmo_poll_gate`` plus an array-child filter. / 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: if not rels:
self.report({"ERROR"}, "No connection found between elements.") self.report({"ERROR"}, "No connection found between elements.")
return return
# All rels between a single pair should share a kind in practice; pick path_objs: list[bpy.types.Object] = []
# the first kind for the cleanup dispatch and remove every rel below. for rel, kind in rels:
kind = rels[0][1] bonsai.core.connection.disconnect_rel(
if kind == "path": tool.Ifc,
for rel, _ in rels: tool.Geometry,
bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) tool.Model,
obj_a = tool.Ifc.get_object(elem_a) tool.Connection,
obj_b = tool.Ifc.get_object(elem_b) rel=rel,
if obj_a is not None and obj_b is not None: kind=kind,
tool.Model.recreate_wall(elem_a, obj_a) elem=elem_a,
tool.Model.recreate_wall(elem_b, obj_b) partner=elem_b,
_resync_walls_after_mutation([obj_a, obj_b]) )
elif kind in ("element-top", "element"): if kind == "path":
for rel, _ in rels: obj_a = tool.Ifc.get_object(elem_a)
wall, slab = tool.Connection.orient_element_top(rel, elem_a, elem_b) obj_b = tool.Ifc.get_object(elem_b)
ifcopenshell.api.geometry.disconnect_element( if obj_a is not None and obj_a not in path_objs:
ifc_file, relating_element=slab, related_element=wall path_objs.append(obj_a)
) if obj_b is not None and obj_b not in path_objs:
if kind == "element-top": path_objs.append(obj_b)
# The TOP rel is what extend_walls_to_underside creates; the if path_objs:
# related side is always the wall. _resync_walls_after_mutation(path_objs)
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])
class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator): 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): class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
"""Activates when exactly one LAYER2 wall is selected. Surfaces an unjoin icon at """Activates when exactly one LAYER2 wall is selected. Surfaces an unjoin icon at
every join location inferred from the wall's IfcRelConnectsPathElements inverse every connection location on the wall — wall-wall path connections via
graph — the single-selection mirror of `GizmoWallJoinIntersection`'s two-wall IfcRelConnectsPathElements + wall-slab underside clips via IfcRelConnectsElements
unjoin state. A wall may participate in many such rels (up to 1 ATSTART + 1 ATEND with Description=="TOP". A wall may participate in many such rels (up to 1 ATSTART
by end, plus unlimited ATPATH T-junctions), so a pool of icons is preallocated + 1 ATEND by end, plus unlimited ATPATH T-junctions, plus one rel per clipped
and hidden on a per-frame basis based on the live connection set. 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 + Each visible icon dispatches `bim.disconnect_elements` with the active wall +
partner wall GlobalIds set on the bound operator properties, so a click removes partner element GlobalIds set on the bound operator properties, so a click
only the single rel under that icon — the other connections on the same wall removes only the single rel under that icon — the other connections on the
survive. same wall survive.
Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group
requires len(selected) == 2; this one requires 1).""" 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. # creation is forbidden — so the pool must be sized upfront for the worst case.
POOL_SIZE = 16 POOL_SIZE = 16
ICON_SCALE = 0.35 ICON_SCALE = 0.35
SLAB_STACK_MAX = 5
SLAB_STACK_OFFSET_Z = 0.5
@classmethod @classmethod
def poll(cls, context: bpy.types.Context) -> bool: 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) billboard_rot = gizmo.get_billboard_rotation(context)
clearance = gizmo.top_down_clearance(context, billboard_rot) clearance = gizmo.top_down_clearance(context, billboard_rot)
connections = _get_wall_connections_cached(self, elem) path_connections = _get_wall_connections_cached(self, elem)
if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False): 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( 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." f"only the first {self.POOL_SIZE} unjoin gizmos are shown."
) )
self._pool_cap_warned = True 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: if slot_idx >= self.POOL_SIZE:
break break
other_obj = tool.Ifc.get_object(other_elem) other_obj = tool.Ifc.get_object(other_elem)
@@ -3966,21 +4022,216 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
continue continue
seg_other = _wall_axis_world_segment_from_geom(other_obj, other_geom) 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) 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 = self.unjoin_icons[slot_idx]
icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE) icon.matrix_basis = gizmo.billboarded_at(location + clearance, billboard_rot, scale=self.ICON_SCALE)
icon.hide = False icon.hide = False
# Only the GlobalId properties are rewritten per frame; the operator self.unjoin_op_props[slot_idx].element_a_guid = slab_elem.GlobalId
# binding itself is the long-lived handle set up at setup() time. GlobalId self.unjoin_op_props[slot_idx].element_b_guid = wall_elem.GlobalId
# (not Blender object name) keeps the binding stable across renames, file icon.partner_obj = wall_obj
# save/reload, and any sit-in-the-undo-stack interlude between dispatch slot_idx += 1
# 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 class GizmoSlabEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
# Mirror the partner reference onto the icon itself so its draw() """Pen / validate / cancel triad for slab disconnect-access mode.
# can outline the partner on hover without a Gizmo-side getter on
# the bound operator (the API exposes target_set_operator with Polls on a single IfcSlab with at least one wall clipped to its underside.
# no symmetric reader). Pen routes through the universal ``bim.enable_editing_parametric``
icon.partner_obj = other_obj 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): class GizmoWallFilletPreview(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
+5
View File
@@ -82,6 +82,7 @@ if TYPE_CHECKING:
BIMPolylineProperties, BIMPolylineProperties,
BIMRailingProperties, BIMRailingProperties,
BIMRoofProperties, BIMRoofProperties,
BIMSlabProperties,
BIMStairProperties, BIMStairProperties,
BIMSverchokProperties, BIMSverchokProperties,
BIMWallProperties, BIMWallProperties,
@@ -118,6 +119,10 @@ class Model(bonsai.core.tool.Model):
def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties: def get_railing_props(cls, obj: bpy.types.Object) -> BIMRailingProperties:
return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue] return obj.BIMRailingProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_slab_props(cls, obj: bpy.types.Object) -> BIMSlabProperties:
return obj.BIMSlabProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod @classmethod
def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties: def get_pipe_segment_props(cls, obj: bpy.types.Object) -> BIMPipeSegmentProperties:
return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue] return obj.BIMPipeSegmentProperties # pyright: ignore[reportAttributeAccessIssue]
+11
View File
@@ -157,6 +157,7 @@ class Parametric(bonsai.core.tool.Parametric):
ParametricObject("pipe_segment", supports_build_edit_lifecycle=True), ParametricObject("pipe_segment", supports_build_edit_lifecycle=True),
ParametricObject("duct_segment", supports_build_edit_lifecycle=True), ParametricObject("duct_segment", supports_build_edit_lifecycle=True),
ParametricObject("wall"), ParametricObject("wall"),
ParametricObject("slab"),
] ]
# Annotations for the uppercase constants populated from ``EDIT_TYPES`` by # Annotations for the uppercase constants populated from ``EDIT_TYPES`` by
@@ -171,6 +172,7 @@ class Parametric(bonsai.core.tool.Parametric):
PIPE_SEGMENT: ClassVar[ParametricObject] PIPE_SEGMENT: ClassVar[ParametricObject]
DUCT_SEGMENT: ClassVar[ParametricObject] DUCT_SEGMENT: ClassVar[ParametricObject]
WALL: ClassVar[ParametricObject] WALL: ClassVar[ParametricObject]
SLAB: ClassVar[ParametricObject]
_geom_generation: int = 0 _geom_generation: int = 0
@@ -459,6 +461,15 @@ class Parametric(bonsai.core.tool.Parametric):
return False return False
return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None 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 @classmethod
def is_wall(cls, element: entity_instance) -> bool: def is_wall(cls, element: entity_instance) -> bool:
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage. """A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage.
+21 -8
View File
@@ -281,22 +281,35 @@ class Wall(bonsai.core.tool.Wall):
return rel return rel
return None 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 @classmethod
def wall_slab_connection_location_world( def wall_slab_connection_location_world(
cls, wall_obj: bpy.types.Object, slab_obj: bpy.types.Object cls, wall_obj: bpy.types.Object, slab_obj: bpy.types.Object
) -> Vector | None: ) -> Vector | None:
"""World-space point where a wall is clipped by a slab — the wall's """World-space anchor for the wall-slab disconnect icon.
axis midpoint lifted to the slab's underside Z. Approximate: uses the
slab's mesh bbox bottom in world space rather than reconstructing the X / Y come from the wall axis midpoint (so the icon sits in the
slab's clip plane. Adequate for icon placement on a wall whose top middle of the wall horizontally); Z is the wall's top in world space
meets the slab; returns ``None`` when the wall has no reference line.""" 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) ref = cls.get_world_reference_line(wall_obj)
if ref is None: if ref is None:
return None return None
axis_mid_world = (ref[0] + ref[1]) * 0.5 axis_mid_world = (ref[0] + ref[1]) * 0.5
slab_bottom_local_z = min(c[2] for c in slab_obj.bound_box) if wall_obj.bound_box:
slab_bottom_world_z = (slab_obj.matrix_world @ Vector((0.0, 0.0, slab_bottom_local_z))).z wall_top_local_z = max(c[2] for c in wall_obj.bound_box)
return Vector((axis_mid_world.x, axis_mid_world.y, slab_bottom_world_z)) 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 @classmethod
def walk_connected_walls( def walk_connected_walls(
@@ -26,6 +26,8 @@ Allow-list (gizmos intentionally outside the rule):
- ``GizmoWallEdition`` single-object parametric edit gizmo. Its base - ``GizmoWallEdition`` single-object parametric edit gizmo. Its base
parametric poll already filters array children. 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 - ``GizmoWallFilletPreview`` the preview-owner whose poll must fire
WHILE its own preview is active; routing it through the topology gate WHILE its own preview is active; routing it through the topology gate
would self-block it. would self-block it.
@@ -47,9 +49,11 @@ pytestmark = pytest.mark.model
# Wall gizmo groups intentionally outside the rule. Add a new entry only # Wall gizmo groups intentionally outside the rule. Add a new entry only
# with the in-code reasoning above. # 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(): def _wall_module_source():
@@ -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(): def test_wall_slab_connection_location_perches_above_wall_top():
"""The icon sits at the wall's axis midpoint X/Y lifted to the slab's """Icon X/Y comes from the wall axis midpoint; Z from the wall's mesh
underside Z so it reads as a marker on the slab cut line.""" 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() wall_obj = Mock()
slab_obj = Mock() wall_obj.matrix_world = Matrix.Identity(4)
slab_obj.matrix_world = Matrix.Translation(Vector((0.0, 0.0, 3.0))) wall_obj.bound_box = [
slab_obj.bound_box = [ (-0.1, -0.1, 0.0),
(-1.0, -1.0, 0.0), (0.1, -0.1, 0.0),
(1.0, -1.0, 0.0), (-0.1, 0.1, 0.0),
(-1.0, 1.0, 0.0), (0.1, 0.1, 0.0),
(1.0, 1.0, 0.0), (-0.1, -0.1, 3.0),
(-1.0, -1.0, 0.2), (0.1, -0.1, 3.0),
(1.0, -1.0, 0.2), (-0.1, 0.1, 3.0),
(-1.0, 1.0, 0.2), (0.1, 0.1, 3.0),
(1.0, 1.0, 0.2),
] ]
slab_obj = Mock()
ref_line = (Vector((1.0, 0.0, 0.0)), Vector((3.0, 0.0, 0.0))) 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): 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) 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(): def test_wall_slab_connection_location_returns_none_for_axisless_wall():