mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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_<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"}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user