mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-22 23:12:34 +00:00
Unify wall disconnect ops via bim.disconnect_elements
Single generic dispatcher replaces UnjoinWallPathConnection + DisconnectWallSlab. Takes two GlobalIds, looks up every supported rel between them via tool.Connection.find_rels, dispatches the right cleanup by rel kind: - path (IfcRelConnectsPathElements): remove_connection on every rel in both orientations + recreate both walls + resync drafts. - element-top (IfcRelConnectsElements with Description=="TOP"): disconnect_element + regenerate_wall_to_underside on the wall side via orient_element_top to recover which input is wall vs slab. - element (other IfcRelConnectsElements): plain disconnect_element. tool.Connection lands as a new tool module with two helpers: - find_rels(a, b): every supported rel between two elements, walking both ConnectedTo + ConnectedFrom (catches both authoring orientations and dedups by id). - find_rel(a, b): first-match convenience. - orient_element_top(rel, a, b): recovers (wall, slab) from a TOP rel regardless of which input came first. Updates GizmoWallUnjoinSingle to target bim.disconnect_elements with both element_a_guid + element_b_guid pre-filled per icon. Adds the single registration in tool/__init__.py and the classes-tuple entry in bim/module/model/__init__.py. Drops the two retired classes. Tests cover both cleanup branches (path + element-top), missing endpoints, no-rel-found, and registration smoke. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -120,8 +120,7 @@ classes = (
|
|||||||
wall.RotateWall90,
|
wall.RotateWall90,
|
||||||
wall.SplitWall,
|
wall.SplitWall,
|
||||||
wall.SplitWallAtCursor,
|
wall.SplitWallAtCursor,
|
||||||
wall.DisconnectWallSlab,
|
wall.DisconnectElements,
|
||||||
wall.UnjoinWallPathConnection,
|
|
||||||
wall.UnjoinWalls,
|
wall.UnjoinWalls,
|
||||||
wall.EnableWallFilletPreview,
|
wall.EnableWallFilletPreview,
|
||||||
wall.FinishWallFilletPreview,
|
wall.FinishWallFilletPreview,
|
||||||
|
|||||||
@@ -285,89 +285,29 @@ class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Oper
|
|||||||
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
|
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
|
||||||
|
|
||||||
|
|
||||||
class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
|
class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||||
"""Surgical counterpart to `UnjoinWalls`: disconnect the active wall from one
|
"""Disconnect two IFC elements given their GlobalIds — generic dispatcher
|
||||||
specific partner wall, leaving the active wall's other connections intact. The
|
that infers the connection rel kind via tool.Connection.find_rels and runs
|
||||||
partner is identified by IFC GlobalId — invariant under Blender-object renames,
|
the right post-disconnect cleanup:
|
||||||
file save/reload, and the undo stack — set on the operator properties by the
|
|
||||||
single-wall unjoin gizmo at click time."""
|
|
||||||
|
|
||||||
bl_idname = "bim.unjoin_wall_path_connection"
|
- ``"path"`` (IfcRelConnectsPathElements) → removes every rel between
|
||||||
bl_label = "Unjoin Wall Connection"
|
the pair (catches both orientations) via remove_connection + recreates
|
||||||
bl_description = "Disconnect the active wall from a single specific partner wall"
|
both walls + resyncs drafts.
|
||||||
|
- ``"element-top"`` (IfcRelConnectsElements with Description=="TOP") →
|
||||||
|
disconnect_element + regenerate_wall_to_underside on the wall side.
|
||||||
|
- ``"element"`` (other IfcRelConnectsElements) → disconnect_element only.
|
||||||
|
|
||||||
|
Both endpoints by GlobalId so the dispatch survives rename / undo / save.
|
||||||
|
Replaces the previous typed UnjoinWallPathConnection + DisconnectWallSlab
|
||||||
|
operators with one entry-point gizmos and shortcuts can bind to."""
|
||||||
|
|
||||||
|
bl_idname = "bim.disconnect_elements"
|
||||||
|
bl_label = "Disconnect Elements"
|
||||||
|
bl_description = "Remove the connection between two IFC elements identified by GlobalId"
|
||||||
bl_options = {"REGISTER", "UNDO"}
|
bl_options = {"REGISTER", "UNDO"}
|
||||||
|
|
||||||
other_wall_guid: bpy.props.StringProperty(name="Other Wall GlobalId")
|
element_a_guid: bpy.props.StringProperty(name="Element A GlobalId")
|
||||||
|
element_b_guid: bpy.props.StringProperty(name="Element B GlobalId")
|
||||||
@classmethod
|
|
||||||
def poll(cls, context):
|
|
||||||
if not tool.Model.has_selected_ifc_objects():
|
|
||||||
cls.poll_message_set("No IFC objects selected.")
|
|
||||||
return False
|
|
||||||
if _poll_reject_array_children(cls):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _perform(self, context):
|
|
||||||
active = tool.Blender.get_active_object(is_selected=True)
|
|
||||||
if not active:
|
|
||||||
self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.")
|
|
||||||
return
|
|
||||||
elem_active = tool.Ifc.get_entity(active)
|
|
||||||
if not elem_active:
|
|
||||||
self.report({"ERROR"}, "Active object is not bound to an IFC entity.")
|
|
||||||
return
|
|
||||||
elem_other = None
|
|
||||||
if self.other_wall_guid:
|
|
||||||
try:
|
|
||||||
elem_other = tool.Ifc.get().by_guid(self.other_wall_guid)
|
|
||||||
except RuntimeError:
|
|
||||||
elem_other = None
|
|
||||||
other = tool.Ifc.get_object(elem_other) if elem_other else None
|
|
||||||
if not elem_other or not other:
|
|
||||||
self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.")
|
|
||||||
return
|
|
||||||
# Walk the inverse graph for the specific IfcRelConnectsPathElements joining
|
|
||||||
# these two walls and remove only that one. `disconnect_path`'s
|
|
||||||
# (relating, related) mode only inspects `relating.ConnectedTo`, so a single
|
|
||||||
# call misses the rel when it was authored with the opposite orientation.
|
|
||||||
rels = [
|
|
||||||
rel
|
|
||||||
for rel in getattr(elem_active, "ConnectedTo", [])
|
|
||||||
if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedElement == elem_other
|
|
||||||
] + [
|
|
||||||
rel
|
|
||||||
for rel in getattr(elem_active, "ConnectedFrom", [])
|
|
||||||
if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingElement == elem_other
|
|
||||||
]
|
|
||||||
for rel in rels:
|
|
||||||
bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel)
|
|
||||||
# Recreate body+axis on both walls so the mesh state matches the IFC mutation
|
|
||||||
# and stale miter cuts are dropped.
|
|
||||||
tool.Model.recreate_wall(elem_active, active)
|
|
||||||
tool.Model.recreate_wall(elem_other, other)
|
|
||||||
_resync_walls_after_mutation([active, other])
|
|
||||||
|
|
||||||
|
|
||||||
class DisconnectWallSlab(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
|
|
||||||
"""Disconnect the wall from one specific underside slab — counterpart to
|
|
||||||
UnjoinWallPathConnection on the wall-slab side. Both endpoints are
|
|
||||||
identified by IFC GlobalId so the dispatch survives rename / undo / save.
|
|
||||||
|
|
||||||
Drops the IfcRelConnectsElements(TOP) rel + all underside booleans on the
|
|
||||||
wall, then re-runs regenerate_wall_to_underside which re-clips the wall
|
|
||||||
to whatever slabs remain connected. The all-booleans-then-regenerate
|
|
||||||
approach is safe with HEAD's flat BBIM_Boolean pset (no per-slab id
|
|
||||||
storage); switches to a per-slab boolean removal when PR #8147's
|
|
||||||
dict-with-slab-guid pset migration lands."""
|
|
||||||
|
|
||||||
bl_idname = "bim.disconnect_wall_slab"
|
|
||||||
bl_label = "Disconnect Wall From Slab"
|
|
||||||
bl_description = "Remove the TOP connection between a wall and one slab and re-clip the wall to remaining slabs"
|
|
||||||
bl_options = {"REGISTER", "UNDO"}
|
|
||||||
|
|
||||||
wall_guid: bpy.props.StringProperty(name="Wall GlobalId")
|
|
||||||
slab_guid: bpy.props.StringProperty(name="Slab GlobalId")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
@@ -381,23 +321,42 @@ class DisconnectWallSlab(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.I
|
|||||||
def _perform(self, context):
|
def _perform(self, context):
|
||||||
ifc_file = tool.Ifc.get()
|
ifc_file = tool.Ifc.get()
|
||||||
try:
|
try:
|
||||||
wall = ifc_file.by_guid(self.wall_guid) if self.wall_guid else None
|
elem_a = ifc_file.by_guid(self.element_a_guid) if self.element_a_guid else None
|
||||||
slab = ifc_file.by_guid(self.slab_guid) if self.slab_guid else None
|
elem_b = ifc_file.by_guid(self.element_b_guid) if self.element_b_guid else None
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
wall = slab = None
|
elem_a = elem_b = None
|
||||||
if wall is None or slab is None:
|
if elem_a is None or elem_b is None:
|
||||||
self.report({"ERROR"}, "Could not resolve wall and slab from supplied GlobalIds.")
|
self.report({"ERROR"}, "Could not resolve elements from supplied GlobalIds.")
|
||||||
return
|
return
|
||||||
wall_obj = tool.Ifc.get_object(wall)
|
rels = tool.Connection.find_rels(elem_a, elem_b)
|
||||||
if wall_obj is None:
|
if not rels:
|
||||||
self.report({"ERROR"}, "Wall has no Blender object.")
|
self.report({"ERROR"}, "No connection found between elements.")
|
||||||
return
|
return
|
||||||
rel = tool.Wall.find_wall_slab_rel(wall, slab)
|
# All rels between a single pair should share a kind in practice; pick
|
||||||
if rel is None:
|
# the first kind for the cleanup dispatch and remove every rel below.
|
||||||
self.report({"ERROR"}, "No TOP connection between this wall and slab.")
|
kind = rels[0][1]
|
||||||
return
|
if kind == "path":
|
||||||
ifcopenshell.api.geometry.disconnect_element(ifc_file, relating_element=slab, related_element=wall)
|
for rel, _ in rels:
|
||||||
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [wall_obj])
|
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])
|
||||||
|
|
||||||
|
|
||||||
class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
|
class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||||
@@ -3912,9 +3871,10 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
|||||||
by end, plus unlimited ATPATH T-junctions), so a pool of icons is preallocated
|
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.
|
and hidden on a per-frame basis based on the live connection set.
|
||||||
|
|
||||||
Each visible icon dispatches `bim.unjoin_wall_path_connection` with the partner
|
Each visible icon dispatches `bim.disconnect_elements` with the active wall +
|
||||||
wall's GlobalId set on the bound operator properties, so a click removes only
|
partner wall GlobalIds set on the bound operator properties, so a click removes
|
||||||
the single rel under that icon — the other connections on the same wall survive.
|
only the single rel under that icon — the other connections on the 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)."""
|
||||||
@@ -3962,11 +3922,11 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
|||||||
self.unjoin_op_props = []
|
self.unjoin_op_props = []
|
||||||
for _ in range(self.POOL_SIZE):
|
for _ in range(self.POOL_SIZE):
|
||||||
icon = self.setup_icon_gizmo(
|
icon = self.setup_icon_gizmo(
|
||||||
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.unjoin_wall_path_connection"
|
"VIEW3D_GT_wall_link_toggle", default_color, highlight_color, "bim.disconnect_elements"
|
||||||
)
|
)
|
||||||
icon.hide = True
|
icon.hide = True
|
||||||
self.unjoin_icons.append(icon)
|
self.unjoin_icons.append(icon)
|
||||||
self.unjoin_op_props.append(icon.target_set_operator("bim.unjoin_wall_path_connection"))
|
self.unjoin_op_props.append(icon.target_set_operator("bim.disconnect_elements"))
|
||||||
|
|
||||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||||
# Default: hide every pool slot. The visible-set is rebuilt from the live
|
# Default: hide every pool slot. The visible-set is rebuilt from the live
|
||||||
@@ -4009,12 +3969,13 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
|||||||
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 partner-GlobalId property is rewritten per frame; the operator
|
# Only the GlobalId properties are rewritten per frame; the operator
|
||||||
# binding itself is the long-lived handle set up at setup() time. GlobalId
|
# binding itself is the long-lived handle set up at setup() time. GlobalId
|
||||||
# (not Blender object name) keeps the binding stable across renames, file
|
# (not Blender object name) keeps the binding stable across renames, file
|
||||||
# save/reload, and any sit-in-the-undo-stack interlude between dispatch
|
# save/reload, and any sit-in-the-undo-stack interlude between dispatch
|
||||||
# and execute.
|
# and execute.
|
||||||
self.unjoin_op_props[slot_idx].other_wall_guid = other_elem.GlobalId
|
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()
|
# Mirror the partner reference onto the icon itself so its draw()
|
||||||
# can outline the partner on hover without a Gizmo-side getter on
|
# can outline the partner on hover without a Gizmo-side getter on
|
||||||
# the bound operator (the API exposes target_set_operator with
|
# the bound operator (the API exposes target_set_operator with
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from bonsai.tool.cad import Cad
|
|||||||
from bonsai.tool.clash import Clash
|
from bonsai.tool.clash import Clash
|
||||||
from bonsai.tool.classification import Classification
|
from bonsai.tool.classification import Classification
|
||||||
from bonsai.tool.collector import Collector
|
from bonsai.tool.collector import Collector
|
||||||
|
from bonsai.tool.connection import Connection
|
||||||
from bonsai.tool.context import Context
|
from bonsai.tool.context import Context
|
||||||
from bonsai.tool.cost import Cost
|
from bonsai.tool.cost import Cost
|
||||||
from bonsai.tool.covering import Covering
|
from bonsai.tool.covering import Covering
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Bonsai - OpenBIM Blender Add-on
|
||||||
|
# Copyright (C) 2026
|
||||||
|
#
|
||||||
|
# This file is part of Bonsai.
|
||||||
|
#
|
||||||
|
# Bonsai is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Bonsai is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
|
||||||
|
"""Generic discovery of the relation linking two IFC elements.
|
||||||
|
|
||||||
|
Used by ``bim.disconnect_elements`` so the operator surface is one operator
|
||||||
|
per disconnect intent (active vs. partner, identified by GlobalId) rather
|
||||||
|
than one per rel class. The kind label returned alongside the rel lets the
|
||||||
|
operator dispatch the right post-disconnect cleanup:
|
||||||
|
|
||||||
|
- ``"path"`` for ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.)
|
||||||
|
- ``"element-top"`` for ``IfcRelConnectsElements`` with ``Description=="TOP"``
|
||||||
|
(the rel kind ``extend_walls_to_underside`` creates)
|
||||||
|
- ``"element"`` for any other ``IfcRelConnectsElements``
|
||||||
|
|
||||||
|
Add new rel kinds by extending :py:meth:`Connection.find_rel`. The disconnect
|
||||||
|
operator's cleanup switch maps each kind to the right post-mutation calls."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import ifcopenshell
|
||||||
|
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
@classmethod
|
||||||
|
def find_rels(
|
||||||
|
cls,
|
||||||
|
elem_a: "ifcopenshell.entity_instance",
|
||||||
|
elem_b: "ifcopenshell.entity_instance",
|
||||||
|
) -> "list[tuple[ifcopenshell.entity_instance, str]]":
|
||||||
|
"""Return every supported rel linking ``elem_a`` to ``elem_b`` as a
|
||||||
|
list of ``(rel, kind)`` tuples. Walks both ``ConnectedTo`` and
|
||||||
|
``ConnectedFrom`` because either side of the rel can be the relating
|
||||||
|
element, and the same pair may carry rels authored with opposite
|
||||||
|
orientations (``disconnect_path``'s ``(relating, related)`` mode only
|
||||||
|
inspects ``relating.ConnectedTo``, so a single call would miss the
|
||||||
|
opposite-orientation rel)."""
|
||||||
|
rels: list[tuple[ifcopenshell.entity_instance, str]] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
|
||||||
|
def _record(rel, kind):
|
||||||
|
if rel.id() not in seen:
|
||||||
|
seen.add(rel.id())
|
||||||
|
rels.append((rel, kind))
|
||||||
|
|
||||||
|
for rel in getattr(elem_a, "ConnectedTo", []) or ():
|
||||||
|
if rel.is_a("IfcRelConnectsPathElements") and getattr(rel, "RelatedElement", None) == elem_b:
|
||||||
|
_record(rel, "path")
|
||||||
|
for rel in getattr(elem_a, "ConnectedFrom", []) or ():
|
||||||
|
if rel.is_a("IfcRelConnectsPathElements") and getattr(rel, "RelatingElement", None) == elem_b:
|
||||||
|
_record(rel, "path")
|
||||||
|
|
||||||
|
for rel in getattr(elem_a, "ConnectedFrom", []) or ():
|
||||||
|
if rel.is_a("IfcRelConnectsElements") and getattr(rel, "RelatingElement", None) == elem_b:
|
||||||
|
kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
|
||||||
|
_record(rel, kind)
|
||||||
|
for rel in getattr(elem_a, "ConnectedTo", []) or ():
|
||||||
|
if rel.is_a("IfcRelConnectsElements") and getattr(rel, "RelatedElement", None) == elem_b:
|
||||||
|
kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
|
||||||
|
_record(rel, kind)
|
||||||
|
|
||||||
|
return rels
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_rel(
|
||||||
|
cls,
|
||||||
|
elem_a: "ifcopenshell.entity_instance",
|
||||||
|
elem_b: "ifcopenshell.entity_instance",
|
||||||
|
) -> "tuple[ifcopenshell.entity_instance | None, str | None]":
|
||||||
|
"""Return the first ``(rel, kind)`` or ``(None, None)``. Cheaper than
|
||||||
|
``find_rels`` when callers only need to know whether a connection
|
||||||
|
exists or what kind it is."""
|
||||||
|
rels = cls.find_rels(elem_a, elem_b)
|
||||||
|
return rels[0] if rels else (None, None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def orient_element_top(
|
||||||
|
cls,
|
||||||
|
rel: "ifcopenshell.entity_instance",
|
||||||
|
elem_a: "ifcopenshell.entity_instance",
|
||||||
|
elem_b: "ifcopenshell.entity_instance",
|
||||||
|
) -> "tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]":
|
||||||
|
"""Return ``(wall, slab)`` for an ``IfcRelConnectsElements(TOP)`` rel.
|
||||||
|
|
||||||
|
The ``extend_walls_to_underside`` flow stores slab as the relating
|
||||||
|
side and wall as related — orientation is recovered by checking
|
||||||
|
which input matches which rel attribute. Callers pass any two
|
||||||
|
elements; this resolves which is the wall and which is the slab so
|
||||||
|
post-disconnect cleanup (regenerate-wall-to-underside) targets the
|
||||||
|
right object."""
|
||||||
|
if getattr(rel, "RelatingElement", None) == elem_a:
|
||||||
|
return elem_b, elem_a
|
||||||
|
return elem_a, elem_b
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# Bonsai - OpenBIM Blender Add-on
|
||||||
|
# Copyright (C) 2026
|
||||||
|
#
|
||||||
|
# This file is part of Bonsai.
|
||||||
|
#
|
||||||
|
# Bonsai is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Bonsai is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
|
||||||
|
"""Behaviour tests for the unified ``bim.disconnect_elements`` operator and
|
||||||
|
``tool.Connection.find_rels`` registry.
|
||||||
|
|
||||||
|
Pin the dispatch contract: rels are found in either orientation; the kind
|
||||||
|
label drives cleanup (``path`` recreates both walls + resyncs drafts;
|
||||||
|
``element-top`` runs ``regenerate_wall_to_underside``); missing endpoints
|
||||||
|
report ERROR rather than crashing."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import bonsai.tool as tool
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.model
|
||||||
|
|
||||||
|
|
||||||
|
def _rel(klass: str, *, relating=None, related=None, description=None, rel_id: int = 0):
|
||||||
|
rel = Mock()
|
||||||
|
rel.is_a = lambda c: c == klass
|
||||||
|
rel.RelatingElement = relating
|
||||||
|
rel.RelatedElement = related
|
||||||
|
rel.Description = description
|
||||||
|
rel.id = lambda: rel_id
|
||||||
|
return rel
|
||||||
|
|
||||||
|
|
||||||
|
def _elem(*, connected_to=(), connected_from=()):
|
||||||
|
e = Mock()
|
||||||
|
e.ConnectedTo = list(connected_to)
|
||||||
|
e.ConnectedFrom = list(connected_from)
|
||||||
|
e.GlobalId = "GUID"
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# tool.Connection.find_rels — registry behaviour
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_rels_returns_path_rel_in_either_orientation():
|
||||||
|
"""The same wall pair can carry path rels authored with either orientation;
|
||||||
|
find_rels must catch both."""
|
||||||
|
elem_a = _elem()
|
||||||
|
elem_b = _elem()
|
||||||
|
rel_ab = _rel("IfcRelConnectsPathElements", related=elem_b, rel_id=1)
|
||||||
|
rel_ba = _rel("IfcRelConnectsPathElements", relating=elem_b, rel_id=2)
|
||||||
|
elem_a.ConnectedTo = [rel_ab]
|
||||||
|
elem_a.ConnectedFrom = [rel_ba]
|
||||||
|
|
||||||
|
rels = tool.Connection.find_rels(elem_a, elem_b)
|
||||||
|
|
||||||
|
assert {r.id() for r, _ in rels} == {1, 2}
|
||||||
|
assert all(k == "path" for _, k in rels)
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_rels_classifies_top_element_rel_specifically():
|
||||||
|
"""IfcRelConnectsElements with Description=='TOP' is the rel kind
|
||||||
|
extend_walls_to_underside creates. Tag it ``element-top`` so the
|
||||||
|
operator can dispatch the regenerate-wall-to-underside cleanup."""
|
||||||
|
wall = _elem()
|
||||||
|
slab = _elem()
|
||||||
|
rel = _rel("IfcRelConnectsElements", relating=slab, description="TOP", rel_id=1)
|
||||||
|
wall.ConnectedFrom = [rel]
|
||||||
|
|
||||||
|
rels = tool.Connection.find_rels(wall, slab)
|
||||||
|
|
||||||
|
assert rels == [(rel, "element-top")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_rels_classifies_non_top_element_rel_generically():
|
||||||
|
"""Other IfcRelConnectsElements descriptions don't get the TOP-specific
|
||||||
|
cleanup. Tag as plain ``element`` so the operator just removes the rel."""
|
||||||
|
elem_a = _elem()
|
||||||
|
elem_b = _elem()
|
||||||
|
rel = _rel("IfcRelConnectsElements", relating=elem_b, description="ATTACHMENT", rel_id=1)
|
||||||
|
elem_a.ConnectedFrom = [rel]
|
||||||
|
|
||||||
|
rels = tool.Connection.find_rels(elem_a, elem_b)
|
||||||
|
|
||||||
|
assert rels == [(rel, "element")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_rels_returns_empty_when_disconnected():
|
||||||
|
elem_a = _elem()
|
||||||
|
elem_b = _elem()
|
||||||
|
assert tool.Connection.find_rels(elem_a, elem_b) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_rels_dedups_by_id():
|
||||||
|
"""A rel that surfaces on both ConnectedTo and ConnectedFrom (in
|
||||||
|
pathological IFC files) should not be returned twice."""
|
||||||
|
elem_a = _elem()
|
||||||
|
elem_b = _elem()
|
||||||
|
rel = _rel("IfcRelConnectsPathElements", related=elem_b, relating=elem_b, rel_id=1)
|
||||||
|
elem_a.ConnectedTo = [rel]
|
||||||
|
elem_a.ConnectedFrom = [rel]
|
||||||
|
|
||||||
|
rels = tool.Connection.find_rels(elem_a, elem_b)
|
||||||
|
|
||||||
|
assert len(rels) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# tool.Connection.find_rel — first-match convenience
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_rel_returns_first_match_or_none_none():
|
||||||
|
elem_a = _elem()
|
||||||
|
elem_b = _elem()
|
||||||
|
rel = _rel("IfcRelConnectsPathElements", related=elem_b, rel_id=1)
|
||||||
|
elem_a.ConnectedTo = [rel]
|
||||||
|
|
||||||
|
assert tool.Connection.find_rel(elem_a, elem_b) == (rel, "path")
|
||||||
|
assert tool.Connection.find_rel(elem_a, _elem()) == (None, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# tool.Connection.orient_element_top — wall / slab orientation recovery
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_orient_element_top_returns_wall_then_slab():
|
||||||
|
"""The TOP rel stores slab as relating + wall as related; orient_element_top
|
||||||
|
figures out which input is which regardless of argument order."""
|
||||||
|
wall = _elem()
|
||||||
|
slab = _elem()
|
||||||
|
rel = _rel("IfcRelConnectsElements", relating=slab, related=wall, description="TOP")
|
||||||
|
|
||||||
|
assert tool.Connection.orient_element_top(rel, wall, slab) == (wall, slab)
|
||||||
|
assert tool.Connection.orient_element_top(rel, slab, wall) == (wall, slab)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# bim.disconnect_elements — dispatch + cleanup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_op(*, a_guid="A", b_guid="B"):
|
||||||
|
op = Mock()
|
||||||
|
op.element_a_guid = a_guid
|
||||||
|
op.element_b_guid = b_guid
|
||||||
|
op.report = Mock()
|
||||||
|
return op
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnect_path_removes_all_rels_then_recreates_walls():
|
||||||
|
from bonsai.bim.module.model.wall import DisconnectElements
|
||||||
|
|
||||||
|
elem_a = Mock()
|
||||||
|
elem_b = Mock()
|
||||||
|
rel1 = Mock()
|
||||||
|
rel2 = Mock()
|
||||||
|
obj_a = Mock()
|
||||||
|
obj_b = Mock()
|
||||||
|
|
||||||
|
ifc_file = MagicMock()
|
||||||
|
ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g]
|
||||||
|
op = _make_op()
|
||||||
|
|
||||||
|
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
|
||||||
|
"bonsai.bim.module.model.wall.tool.Connection.find_rels",
|
||||||
|
return_value=[(rel1, "path"), (rel2, "path")],
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.wall.tool.Ifc.get_object", side_effect=lambda e: {elem_a: obj_a, elem_b: obj_b}[e]
|
||||||
|
), patch("bonsai.bim.module.model.wall.bonsai.core.geometry.remove_connection") as remove, patch(
|
||||||
|
"bonsai.bim.module.model.wall.tool.Model.recreate_wall"
|
||||||
|
) as recreate, patch("bonsai.bim.module.model.wall._resync_walls_after_mutation") as resync:
|
||||||
|
DisconnectElements._perform(op, context=MagicMock())
|
||||||
|
|
||||||
|
assert remove.call_count == 2
|
||||||
|
assert recreate.call_count == 2
|
||||||
|
resync.assert_called_once_with([obj_a, obj_b])
|
||||||
|
op.report.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnect_element_top_calls_regenerate():
|
||||||
|
from bonsai.bim.module.model.wall import DisconnectElements
|
||||||
|
|
||||||
|
wall = Mock()
|
||||||
|
slab = Mock()
|
||||||
|
wall_obj = Mock()
|
||||||
|
rel = Mock()
|
||||||
|
rel.RelatedElement = wall
|
||||||
|
|
||||||
|
ifc_file = MagicMock()
|
||||||
|
ifc_file.by_guid.side_effect = lambda g: {"A": wall, "B": slab}[g]
|
||||||
|
op = _make_op()
|
||||||
|
|
||||||
|
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
|
||||||
|
"bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[(rel, "element-top")]
|
||||||
|
), patch(
|
||||||
|
"bonsai.bim.module.model.wall.tool.Connection.orient_element_top", return_value=(wall, slab)
|
||||||
|
), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=wall_obj), patch(
|
||||||
|
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element"
|
||||||
|
) as disc, patch("bonsai.bim.module.model.wall.core.regenerate_wall_to_underside") as regen:
|
||||||
|
DisconnectElements._perform(op, context=MagicMock())
|
||||||
|
|
||||||
|
disc.assert_called_once_with(ifc_file, relating_element=slab, related_element=wall)
|
||||||
|
regen.assert_called_once()
|
||||||
|
op.report.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnect_reports_on_unknown_guids():
|
||||||
|
from bonsai.bim.module.model.wall import DisconnectElements
|
||||||
|
|
||||||
|
ifc_file = MagicMock()
|
||||||
|
ifc_file.by_guid.side_effect = RuntimeError("missing")
|
||||||
|
op = _make_op(a_guid="MISSING_A", b_guid="MISSING_B")
|
||||||
|
|
||||||
|
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
|
||||||
|
"bonsai.bim.module.model.wall.tool.Connection.find_rels"
|
||||||
|
) as find:
|
||||||
|
DisconnectElements._perform(op, context=MagicMock())
|
||||||
|
|
||||||
|
find.assert_not_called()
|
||||||
|
op.report.assert_called_once()
|
||||||
|
args, _ = op.report.call_args
|
||||||
|
assert args[0] == {"ERROR"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnect_reports_when_no_rel_found():
|
||||||
|
from bonsai.bim.module.model.wall import DisconnectElements
|
||||||
|
|
||||||
|
elem_a = Mock()
|
||||||
|
elem_b = Mock()
|
||||||
|
ifc_file = MagicMock()
|
||||||
|
ifc_file.by_guid.side_effect = lambda g: {"A": elem_a, "B": elem_b}[g]
|
||||||
|
op = _make_op()
|
||||||
|
|
||||||
|
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
|
||||||
|
"bonsai.bim.module.model.wall.tool.Connection.find_rels", return_value=[]
|
||||||
|
):
|
||||||
|
DisconnectElements._perform(op, context=MagicMock())
|
||||||
|
|
||||||
|
op.report.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnect_operator_is_registered():
|
||||||
|
from bonsai.bim.module import model
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
getattr(cls, "bl_idname", None) == "bim.disconnect_elements" for cls in model.classes
|
||||||
|
), "DisconnectElements is not in the model classes tuple"
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
# Bonsai - OpenBIM Blender Add-on
|
|
||||||
# Copyright (C) 2026
|
|
||||||
#
|
|
||||||
# This file is part of Bonsai.
|
|
||||||
#
|
|
||||||
# Bonsai is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# Bonsai is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# This file was generated with the assistance of an AI coding tool.
|
|
||||||
|
|
||||||
"""Behaviour tests for ``bim.disconnect_wall_slab``.
|
|
||||||
|
|
||||||
Pins the dispatch contract: resolves the wall + slab from GlobalIds, finds the
|
|
||||||
specific ``IfcRelConnectsElements(TOP)`` rel, removes it via the IFC API, then
|
|
||||||
delegates to ``core.regenerate_wall_to_underside`` to re-clip the wall against
|
|
||||||
any remaining slab connections."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.model
|
|
||||||
|
|
||||||
|
|
||||||
def _make_op(*, wall_guid="WALL-GUID", slab_guid="SLAB-GUID"):
|
|
||||||
op = Mock()
|
|
||||||
op.wall_guid = wall_guid
|
|
||||||
op.slab_guid = slab_guid
|
|
||||||
op.report = Mock()
|
|
||||||
return op
|
|
||||||
|
|
||||||
|
|
||||||
def _ifc_file_with(*, walls: dict | None = None, slabs: dict | None = None):
|
|
||||||
ifc = MagicMock(name="ifc_file")
|
|
||||||
walls = walls or {}
|
|
||||||
slabs = slabs or {}
|
|
||||||
|
|
||||||
def _by_guid(guid):
|
|
||||||
if guid in walls:
|
|
||||||
return walls[guid]
|
|
||||||
if guid in slabs:
|
|
||||||
return slabs[guid]
|
|
||||||
raise RuntimeError(f"no entity with guid {guid}")
|
|
||||||
|
|
||||||
ifc.by_guid.side_effect = _by_guid
|
|
||||||
return ifc
|
|
||||||
|
|
||||||
|
|
||||||
def test_disconnect_removes_rel_then_regenerates():
|
|
||||||
"""Happy path: resolve both endpoints, find rel, call disconnect_element,
|
|
||||||
then regenerate so remaining slabs re-clip cleanly."""
|
|
||||||
from bonsai.bim.module.model.wall import DisconnectWallSlab
|
|
||||||
|
|
||||||
wall = Mock(name="wall")
|
|
||||||
slab = Mock(name="slab")
|
|
||||||
rel = Mock(name="rel")
|
|
||||||
wall_obj = Mock(name="wall_obj")
|
|
||||||
ifc_file = _ifc_file_with(walls={"WALL-GUID": wall}, slabs={"SLAB-GUID": slab})
|
|
||||||
op = _make_op()
|
|
||||||
|
|
||||||
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
|
|
||||||
"bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=wall_obj
|
|
||||||
), patch("bonsai.bim.module.model.wall.tool.Wall.find_wall_slab_rel", return_value=rel), patch(
|
|
||||||
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element"
|
|
||||||
) as disconnect, patch(
|
|
||||||
"bonsai.bim.module.model.wall.core.regenerate_wall_to_underside"
|
|
||||||
) as regen:
|
|
||||||
DisconnectWallSlab._perform(op, context=MagicMock())
|
|
||||||
|
|
||||||
disconnect.assert_called_once_with(ifc_file, relating_element=slab, related_element=wall)
|
|
||||||
regen.assert_called_once()
|
|
||||||
args, _ = regen.call_args
|
|
||||||
assert args[3] == [wall_obj]
|
|
||||||
op.report.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_disconnect_reports_when_guids_unknown():
|
|
||||||
"""Stale UI state can dispatch with guids no longer in the file — surface
|
|
||||||
an ERROR rather than crashing on RuntimeError from by_guid."""
|
|
||||||
from bonsai.bim.module.model.wall import DisconnectWallSlab
|
|
||||||
|
|
||||||
ifc_file = _ifc_file_with()
|
|
||||||
op = _make_op(wall_guid="MISSING", slab_guid="ALSO-MISSING")
|
|
||||||
|
|
||||||
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
|
|
||||||
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element"
|
|
||||||
) as disconnect, patch("bonsai.bim.module.model.wall.core.regenerate_wall_to_underside") as regen:
|
|
||||||
DisconnectWallSlab._perform(op, context=MagicMock())
|
|
||||||
|
|
||||||
disconnect.assert_not_called()
|
|
||||||
regen.assert_not_called()
|
|
||||||
op.report.assert_called_once()
|
|
||||||
args, _ = op.report.call_args
|
|
||||||
assert args[0] == {"ERROR"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_disconnect_reports_when_rel_missing():
|
|
||||||
"""find_wall_slab_rel returns None when the rel doesn't exist (UI was
|
|
||||||
showing a stale icon). Operator reports + skips the mutation."""
|
|
||||||
from bonsai.bim.module.model.wall import DisconnectWallSlab
|
|
||||||
|
|
||||||
wall = Mock(name="wall")
|
|
||||||
slab = Mock(name="slab")
|
|
||||||
wall_obj = Mock(name="wall_obj")
|
|
||||||
ifc_file = _ifc_file_with(walls={"WALL-GUID": wall}, slabs={"SLAB-GUID": slab})
|
|
||||||
op = _make_op()
|
|
||||||
|
|
||||||
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
|
|
||||||
"bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=wall_obj
|
|
||||||
), patch("bonsai.bim.module.model.wall.tool.Wall.find_wall_slab_rel", return_value=None), patch(
|
|
||||||
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element"
|
|
||||||
) as disconnect, patch(
|
|
||||||
"bonsai.bim.module.model.wall.core.regenerate_wall_to_underside"
|
|
||||||
) as regen:
|
|
||||||
DisconnectWallSlab._perform(op, context=MagicMock())
|
|
||||||
|
|
||||||
disconnect.assert_not_called()
|
|
||||||
regen.assert_not_called()
|
|
||||||
op.report.assert_called_once()
|
|
||||||
args, _ = op.report.call_args
|
|
||||||
assert args[0] == {"ERROR"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_disconnect_reports_when_wall_obj_missing():
|
|
||||||
"""The wall entity exists but has no Blender object — surface ERROR
|
|
||||||
rather than silently no-op (or crash trying to pass None to regen)."""
|
|
||||||
from bonsai.bim.module.model.wall import DisconnectWallSlab
|
|
||||||
|
|
||||||
wall = Mock(name="wall")
|
|
||||||
slab = Mock(name="slab")
|
|
||||||
ifc_file = _ifc_file_with(walls={"WALL-GUID": wall}, slabs={"SLAB-GUID": slab})
|
|
||||||
op = _make_op()
|
|
||||||
|
|
||||||
with patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=ifc_file), patch(
|
|
||||||
"bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=None
|
|
||||||
), patch("bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_element") as disconnect, patch(
|
|
||||||
"bonsai.bim.module.model.wall.core.regenerate_wall_to_underside"
|
|
||||||
) as regen:
|
|
||||||
DisconnectWallSlab._perform(op, context=MagicMock())
|
|
||||||
|
|
||||||
disconnect.assert_not_called()
|
|
||||||
regen.assert_not_called()
|
|
||||||
op.report.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
def test_disconnect_operator_is_registered():
|
|
||||||
"""Catches a forgotten classes-tuple update — the operator file can be
|
|
||||||
saved cleanly but the class never reaches Blender's registry without
|
|
||||||
the __init__.py entry."""
|
|
||||||
from bonsai.bim.module import model
|
|
||||||
|
|
||||||
assert any(
|
|
||||||
getattr(cls, "bl_idname", None) == "bim.disconnect_wall_slab" for cls in model.classes
|
|
||||||
), "DisconnectWallSlab is not in the model classes tuple"
|
|
||||||
Reference in New Issue
Block a user