mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-12 02:23:34 +00:00
Merge pull request #8173 from Gorgious56/bonsai/wall-slab-gizmos
Bonsai/wall slab gizmos
This commit is contained in:
@@ -890,6 +890,16 @@ class OverrideDelete(bpy.types.Operator):
|
||||
# Track aggregates before deleting their parts
|
||||
aggregates_to_check = self.track_aggregates(objects_to_remove)
|
||||
|
||||
# Snapshot the set of IFC entity ids being deleted in this batch so the
|
||||
# connection-rel cascade inside `delete_ifc_object` can suppress
|
||||
# partner-side regenerate when the partner is also about to vanish.
|
||||
batch_being_deleted_ids: set[int] = set()
|
||||
for obj in objects_to_remove:
|
||||
if not tool.Blender.is_valid_data_block(obj):
|
||||
continue
|
||||
if (entity := tool.Ifc.get_entity(obj)) is not None:
|
||||
batch_being_deleted_ids.add(entity.id())
|
||||
|
||||
clear_active_object = True
|
||||
|
||||
for i, obj in enumerate(objects_to_remove, 1):
|
||||
@@ -931,7 +941,7 @@ class OverrideDelete(bpy.types.Operator):
|
||||
if tool.Drawing.is_auto_annotation(element):
|
||||
self.report({"INFO"}, "References cannot be deleted. Exclude the referenced element instead.")
|
||||
continue
|
||||
tool.Geometry.delete_ifc_object(obj)
|
||||
tool.Geometry.delete_ifc_object(obj, batch_being_deleted_ids=batch_being_deleted_ids)
|
||||
elif tool.Geometry.is_representation_item(obj):
|
||||
tool.Geometry.delete_ifc_item(obj)
|
||||
else:
|
||||
|
||||
@@ -110,6 +110,8 @@ classes = (
|
||||
wall.GizmoWallFilletPreview,
|
||||
wall.GizmoWallFilletReedit,
|
||||
wall.GizmoWallFilletToggleOpenings,
|
||||
wall.GizmoSlabEdition,
|
||||
wall.GizmoSlabUnjoinWalls,
|
||||
wall.GizmoWallJoinIntersection,
|
||||
wall.GizmoWallLinkToggle,
|
||||
wall.GizmoWallUnjoinSingle,
|
||||
@@ -120,7 +122,7 @@ classes = (
|
||||
wall.RotateWall90,
|
||||
wall.SplitWall,
|
||||
wall.SplitWallAtCursor,
|
||||
wall.UnjoinWallPathConnection,
|
||||
wall.DisconnectElements,
|
||||
wall.UnjoinWalls,
|
||||
wall.EnableWallFilletPreview,
|
||||
wall.FinishWallFilletPreview,
|
||||
@@ -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,
|
||||
|
||||
@@ -51,6 +51,10 @@ class AuthoringData:
|
||||
|
||||
@classmethod
|
||||
def load(cls, ifc_element_type: Optional[str] = None):
|
||||
# ``is_loaded`` is set first as a recursion guard: one of the data
|
||||
# computations evaluates a PropertyGroup enum's ``items`` callback,
|
||||
# which re-enters this method. Without the guard, load recurses to
|
||||
# RecursionError.
|
||||
cls.is_loaded = True
|
||||
cls.props = tool.Model.get_model_props()
|
||||
cls.data["default_container"] = cls.default_container()
|
||||
|
||||
@@ -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(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.
|
||||
@@ -246,6 +291,15 @@ def _resync_walls_after_mutation(objs: Iterable["bpy.types.Object | None"]) -> N
|
||||
_maybe_resync_wall_props_from_ifc(obj)
|
||||
|
||||
|
||||
def _regenerate_walls(objs: "Iterable[bpy.types.Object | None]") -> None:
|
||||
"""Rebuild every wall in ``objs`` from current IFC state — extrusion,
|
||||
openings, and any underside slab clip — so the caller doesn't carry
|
||||
feature-specific dispatch."""
|
||||
for obj in objs:
|
||||
if obj is not None:
|
||||
tool.Model.regenerate_wall(obj)
|
||||
|
||||
|
||||
class _CommitWallDraftsFirstMixin:
|
||||
"""Operator mixin that flushes any in-progress wall parametric drafts in
|
||||
the current selection before delegating to the subclass's ``_perform``.
|
||||
@@ -285,19 +339,29 @@ class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Oper
|
||||
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
|
||||
|
||||
|
||||
class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Surgical counterpart to `UnjoinWalls`: disconnect the active wall from one
|
||||
specific partner wall, leaving the active wall's other connections intact. The
|
||||
partner is identified by IFC GlobalId — invariant under Blender-object renames,
|
||||
file save/reload, and the undo stack — set on the operator properties by the
|
||||
single-wall unjoin gizmo at click time."""
|
||||
class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operator):
|
||||
"""Disconnect two IFC elements given their GlobalIds — generic dispatcher
|
||||
that infers the connection rel kind via tool.Connection.find_rels and runs
|
||||
the right post-disconnect cleanup:
|
||||
|
||||
bl_idname = "bim.unjoin_wall_path_connection"
|
||||
bl_label = "Unjoin Wall Connection"
|
||||
bl_description = "Disconnect the active wall from a single specific partner wall"
|
||||
- ``"path"`` (IfcRelConnectsPathElements) → removes every rel between
|
||||
the pair (catches both orientations) via remove_connection + recreates
|
||||
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"}
|
||||
|
||||
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):
|
||||
@@ -309,44 +373,54 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator,
|
||||
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.")
|
||||
ifc_file = tool.Ifc.get()
|
||||
try:
|
||||
elem_a = ifc_file.by_guid(self.element_a_guid) if self.element_a_guid else None
|
||||
elem_b = ifc_file.by_guid(self.element_b_guid) if self.element_b_guid else None
|
||||
except RuntimeError:
|
||||
elem_a = elem_b = None
|
||||
if elem_a is None or elem_b is None:
|
||||
self.report({"ERROR"}, "Could not resolve elements from supplied GlobalIds.")
|
||||
return
|
||||
elem_active = tool.Ifc.get_entity(active)
|
||||
if not elem_active:
|
||||
self.report({"ERROR"}, "Active object is not bound to an IFC entity.")
|
||||
rels = tool.Connection.find_rels(elem_a, elem_b)
|
||||
if not rels:
|
||||
self.report({"ERROR"}, "No connection found between elements.")
|
||||
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.")
|
||||
# The fillet corner's join with its source walls defines the fillet's
|
||||
# identity — unjoining there would tear down the chord axis reference
|
||||
# without rebuilding the source walls' miter cuts. Deleting the corner
|
||||
# wall is the supported teardown, which cascades back to the source
|
||||
# walls via the connection-cleanup handler.
|
||||
either_is_fillet = tool.Parametric.is_fillet_corner_wall(elem_a) or tool.Parametric.is_fillet_corner_wall(
|
||||
elem_b
|
||||
)
|
||||
if either_is_fillet and any(k == "path" for _, k in rels):
|
||||
self.report(
|
||||
{"INFO"},
|
||||
"Fillet wall path connections can't be unjoined — delete the fillet wall element to remove the corner.",
|
||||
)
|
||||
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])
|
||||
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):
|
||||
@@ -369,7 +443,7 @@ class ExtendWallsToUnderside(_CommitWallDraftsFirstMixin, bpy.types.Operator, to
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
continue
|
||||
if tool.Model.get_usage_type(element) == "LAYER2":
|
||||
if tool.Parametric.is_path_connectable_wall(element):
|
||||
walls.append(obj)
|
||||
else:
|
||||
slabs.append(obj)
|
||||
@@ -390,7 +464,7 @@ class RegenerateWallToUnderside(bpy.types.Operator, tool.Ifc.Operator):
|
||||
wall_objs = [
|
||||
obj
|
||||
for obj in tool.Blender.get_selected_objects()
|
||||
if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2"
|
||||
if (element := tool.Ifc.get_entity(obj)) and tool.Parametric.is_path_connectable_wall(element)
|
||||
]
|
||||
if wall_objs:
|
||||
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs)
|
||||
@@ -642,9 +716,14 @@ class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
|
||||
|
||||
def _perform(self, context):
|
||||
selected_objs = tool.Model.get_selected_mesh_objects()
|
||||
post_split_walls: list[bpy.types.Object] = []
|
||||
for obj in selected_objs:
|
||||
DumbWallJoiner().split(obj, context.scene.cursor.location)
|
||||
_resync_walls_after_mutation(selected_objs)
|
||||
new_obj = DumbWallJoiner().split(obj, context.scene.cursor.location)
|
||||
post_split_walls.append(obj)
|
||||
if new_obj is not None and new_obj not in post_split_walls:
|
||||
post_split_walls.append(new_obj)
|
||||
_resync_walls_after_mutation(post_split_walls)
|
||||
_regenerate_walls(post_split_walls)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -674,11 +753,13 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
|
||||
active_obj = context.active_object
|
||||
assert active_obj
|
||||
selected_objs = tool.Model.get_selected_mesh_objects()
|
||||
# The merge deletes the second argument when the walls are collinear;
|
||||
# only the first survives, so the resync targets the non-active wall.
|
||||
surviving_obj = next(o for o in selected_objs if o != active_obj)
|
||||
DumbWallJoiner().merge(surviving_obj, active_obj)
|
||||
_maybe_resync_wall_props_from_ifc(surviving_obj)
|
||||
# Active-is-survivor — matches Blender's Ctrl+J / "merge at last"
|
||||
# convention. The first argument survives, the second is consumed,
|
||||
# so the active wall ends up absorbing the other.
|
||||
other_obj = next(o for o in selected_objs if o != active_obj)
|
||||
DumbWallJoiner().merge(active_obj, other_obj)
|
||||
_maybe_resync_wall_props_from_ifc(active_obj)
|
||||
_regenerate_walls([active_obj])
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -745,6 +826,7 @@ class ChangeExtrusionDepth(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
if layer2_objs:
|
||||
tool.Model.recalculate_walls(layer2_objs)
|
||||
_resync_walls_after_mutation(layer2_objs)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -860,6 +942,7 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
if layer2_objs:
|
||||
tool.Model.recalculate_walls(layer2_objs)
|
||||
_resync_walls_after_mutation(layer2_objs)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -882,6 +965,7 @@ class ChangeLayerLength(bpy.types.Operator, tool.Ifc.Operator):
|
||||
selected_objs = tool.Model.get_selected_mesh_ifc_objects()
|
||||
for obj in selected_objs:
|
||||
joiner.set_length(obj, self.length)
|
||||
_resync_walls_after_mutation(selected_objs)
|
||||
|
||||
|
||||
class OffsetWalls(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -1463,7 +1547,7 @@ class DumbWallJoiner:
|
||||
body = copy.deepcopy(axis1["reference"])
|
||||
tool.Model.recreate_wall(element1, wall1)
|
||||
|
||||
def split(self, wall1: bpy.types.Object, target: Vector) -> None:
|
||||
def split(self, wall1: bpy.types.Object, target: Vector) -> "bpy.types.Object | None":
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
|
||||
element1 = tool.Ifc.get_entity(wall1)
|
||||
@@ -1484,6 +1568,13 @@ class DumbWallJoiner:
|
||||
wall2 = self.duplicate_wall(wall1)
|
||||
element2 = tool.Ifc.get_entity(wall2)
|
||||
|
||||
# The duplicate inherits wall1's slab-trim boolean chain (copied by
|
||||
# copy_class) but ``BBIM_Boolean.Data`` carries wall1's stale ids, so
|
||||
# ``get_manual_booleans(element2)`` returns empty and the regenerator
|
||||
# rebuilds wall2's body without those clips. Strip them up front so
|
||||
# wall2 starts clean before the axis + placement reshape.
|
||||
tool.Model.strip_underside_booleans(element2)
|
||||
|
||||
# Get the ATEND connection from wall1 to use it in wall2
|
||||
relating_element = None
|
||||
connections = element1.ConnectedTo
|
||||
@@ -1543,13 +1634,16 @@ class DumbWallJoiner:
|
||||
r.RelatedOpeningElement for r in list(element1.HasOpenings) if r.RelatedOpeningElement.HasFillings
|
||||
]:
|
||||
rel = opening.HasFillings[0]
|
||||
filling = rel.RelatedBuildingElement
|
||||
filling_obj = tool.Ifc.get_object(filling)
|
||||
filling_location = filling_obj.matrix_world.translation
|
||||
_, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis_world_2d)
|
||||
min_t, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale)
|
||||
# Use the opening's axis-projected midpoint to classify the side.
|
||||
# The filling's ``matrix_world.translation`` is flip-fragile —
|
||||
# flipping rotates the filler 180° + translates so the bbox
|
||||
# stays visually in place, moving the door origin to the
|
||||
# opposite corner, which would mis-classify a flipped door
|
||||
# centred over the cut.
|
||||
opening_midpoint = (min_t + max_t) / 2
|
||||
void_straddles = min_t < cut_percentage < max_t
|
||||
if filling_position > cut_percentage:
|
||||
if opening_midpoint > cut_percentage:
|
||||
# The filling should be moved from element1 to element2.
|
||||
new_opening = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=opening)
|
||||
new_opening.VoidsElements[0].RelatingBuildingElement = element2
|
||||
@@ -1564,13 +1658,16 @@ class DumbWallJoiner:
|
||||
|
||||
rel.RelatingOpeningElement = new_opening
|
||||
|
||||
# Remove the old opening
|
||||
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
||||
|
||||
if void_straddles:
|
||||
# Filling moved to element2, but void straddles — add a
|
||||
# pure-void copy back to element1 so its body still gets cut.
|
||||
_add_void_copy(element1, new_opening)
|
||||
# pure-void copy back to element1. Read from the original
|
||||
# ``opening`` whose ObjectPlacement still references
|
||||
# element1; ``new_opening`` was rebound to element2 and
|
||||
# would copy element2's frame instead.
|
||||
_add_void_copy(element1, opening)
|
||||
|
||||
# Remove the old opening
|
||||
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
||||
elif void_straddles:
|
||||
# Filling stays on element1, but void straddles — add a pure-void
|
||||
# copy to element2 so its body gets cut.
|
||||
@@ -1583,6 +1680,7 @@ class DumbWallJoiner:
|
||||
|
||||
tool.Model.recreate_wall(element1, wall1)
|
||||
tool.Model.recreate_wall(element2, wall2)
|
||||
return wall2
|
||||
|
||||
def flip(self, wall1: bpy.types.Object) -> None:
|
||||
if tool.Ifc.is_moved(wall1):
|
||||
@@ -1638,7 +1736,14 @@ class DumbWallJoiner:
|
||||
p2[0] = max(x_ordinates)
|
||||
self.set_axis(element1, p1, p2)
|
||||
|
||||
# ConnectedTo / ConnectedFrom carry both ``IfcRelConnectsPathElements``
|
||||
# (the wall-wall joins this loop migrates) and
|
||||
# ``IfcRelConnectsElements`` (the slab underside clip). Only the
|
||||
# path rels expose ``RelatingConnectionType`` / ``RelatedConnectionType``;
|
||||
# the element rels die with element2 via the trailing cascade delete.
|
||||
for rel in element2.ConnectedTo:
|
||||
if not rel.is_a("IfcRelConnectsPathElements"):
|
||||
continue
|
||||
ifcopenshell.api.geometry.disconnect_path(
|
||||
tool.Ifc.get(), element=element1, connection_type=rel.RelatingConnectionType
|
||||
)
|
||||
@@ -1651,6 +1756,8 @@ class DumbWallJoiner:
|
||||
)
|
||||
|
||||
for rel in element2.ConnectedFrom:
|
||||
if not rel.is_a("IfcRelConnectsPathElements"):
|
||||
continue
|
||||
ifcopenshell.api.geometry.disconnect_path(
|
||||
tool.Ifc.get(), element=element1, connection_type=rel.RelatedConnectionType
|
||||
)
|
||||
@@ -1662,6 +1769,26 @@ class DumbWallJoiner:
|
||||
related_connection=rel.RelatedConnectionType,
|
||||
)
|
||||
|
||||
# Re-host openings from the discarded wall to the survivor before
|
||||
# the cascade delete tears down element2's voids and any filling
|
||||
# that depends on them. ``edit_object_placement`` preserves the
|
||||
# opening's world position when element1 and element2 have
|
||||
# different placements — a ``PlacementRelTo`` swap alone would
|
||||
# shift the opening as the relative offset changes.
|
||||
ifc_file = tool.Ifc.get()
|
||||
for rel in list(element2.HasOpenings):
|
||||
opening = rel.RelatedOpeningElement
|
||||
rel.RelatingBuildingElement = element1
|
||||
if opening.ObjectPlacement:
|
||||
world_matrix = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement)
|
||||
ifcopenshell.api.geometry.edit_object_placement(
|
||||
ifc_file,
|
||||
product=opening,
|
||||
matrix=world_matrix,
|
||||
is_si=False,
|
||||
should_transform_children=False,
|
||||
)
|
||||
|
||||
tool.Model.recreate_wall(element1, wall1)
|
||||
|
||||
tool.Geometry.delete_ifc_object(wall2)
|
||||
@@ -2458,7 +2585,9 @@ class ExtendWallToCursor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
tool.Model,
|
||||
context.scene.cursor.location,
|
||||
)
|
||||
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
|
||||
affected = list(tool.Blender.get_selected_objects())
|
||||
_resync_walls_after_mutation(affected)
|
||||
_regenerate_walls(affected)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -2491,6 +2620,7 @@ class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator):
|
||||
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
|
||||
bpy.ops.bim.change_extrusion_depth(depth=new_height)
|
||||
_maybe_resync_wall_props_from_ifc(obj)
|
||||
_regenerate_walls([obj])
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -3117,6 +3247,12 @@ def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bp
|
||||
# the banana body. If a neighbour moved, the new placement follows; if
|
||||
# neither moved, the new matrix equals the old within floating-point noise.
|
||||
_apply_fillet_corner_geometry(ifc_file, obj, geom, wall_a_obj)
|
||||
# The body rebuild swaps the wall's representation, so any prior underside
|
||||
# clip is gone. Re-clip from the surviving TOP rels so an extend-to-slab
|
||||
# applied to a fillet wall isn't silently wiped on the next neighbour
|
||||
# recalc, ChangeExtrusionDepth, or split / merge call site.
|
||||
if tool.Model.has_underside_connection(element):
|
||||
core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, [obj])
|
||||
|
||||
|
||||
class EnableWallFilletPreview(bpy.types.Operator):
|
||||
@@ -3236,6 +3372,19 @@ class CancelWallFilletPreview(bpy.types.Operator):
|
||||
props = preview_base.get_preview_props(context, "wall_fillet")
|
||||
if props is None or not props.is_active:
|
||||
return {"CANCELLED"}
|
||||
# Clear the corner's edit flag so the connection disconnect gizmos
|
||||
# disappear in lockstep with the radius preview when the user
|
||||
# cancels. The id read happens BEFORE clear_preview_state wipes it.
|
||||
corner_id = props.editing_corner_id
|
||||
if corner_id:
|
||||
ifc_file = tool.Ifc.get()
|
||||
if ifc_file is not None:
|
||||
try:
|
||||
corner_obj = tool.Ifc.get_object(ifc_file.by_id(corner_id))
|
||||
except RuntimeError:
|
||||
corner_obj = None
|
||||
if corner_obj is not None:
|
||||
tool.Model.get_wall_props(corner_obj).is_editing = False
|
||||
preview_base.clear_preview_state(props)
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -3309,6 +3458,11 @@ class EnableWallFilletPreviewFromCorner(bpy.types.Operator):
|
||||
props.radius = float(radius)
|
||||
props.editing_corner_id = corner_elem.id()
|
||||
props.is_active = True
|
||||
# Flag the corner as "in edit mode" so the wall-side connection
|
||||
# disconnect gizmos surface in parallel with the fillet preview —
|
||||
# one pen-icon click enters BOTH radius retune AND connection
|
||||
# inspection.
|
||||
tool.Model.get_wall_props(corner_obj).is_editing = True
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -3589,7 +3743,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
||||
return False
|
||||
other = next(o for o in selected if o is not active)
|
||||
other_element = tool.Ifc.get_entity(other)
|
||||
if not other_element or tool.Model.get_usage_type(other_element) != "LAYER2":
|
||||
if not other_element or not tool.Parametric.is_path_connectable_wall(other_element):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -3855,15 +4009,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.unjoin_wall_path_connection` with the partner
|
||||
wall's GlobalId 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.
|
||||
Each visible icon dispatches `bim.disconnect_elements` with the active wall +
|
||||
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)."""
|
||||
@@ -3881,10 +4037,25 @@ 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
|
||||
# Muted gray used for connection icons that are visible (the connection
|
||||
# exists) but inert (clicking dispatches a no-op + INFO report). Fillet
|
||||
# corner ↔ source-wall joins use this — disconnecting them would tear
|
||||
# down the fillet's chord axis reference, so the supported teardown is
|
||||
# deleting the corner wall instead.
|
||||
LOCKED_COLOR: ClassVar[tuple[float, float, float]] = (0.5, 0.5, 0.5)
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not _wall_topology_gizmo_poll_gate(context):
|
||||
# Bypass the shared topology gate's ``any_preview_active`` block —
|
||||
# ``BIMWallProperties.is_editing`` is the real gate for this gizmo
|
||||
# group, and that flag is set both by the regular wall edit lifecycle
|
||||
# AND by the fillet preview entry (so a fillet corner under preview
|
||||
# surfaces its connections in parallel with the radius drag).
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return False
|
||||
if tool.Blender.Modifier.any_selected_is_array_child():
|
||||
return False
|
||||
active = tool.Blender.get_active_object(is_selected=True)
|
||||
if active is None:
|
||||
@@ -3902,6 +4073,9 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
# Stashed so per-frame ``_bind_unjoin_icon`` can restore the active
|
||||
# tone when an icon was muted in a previous frame for fillet lock.
|
||||
self._default_unjoin_color = default_color
|
||||
# Bind the operator on each pool icon ONCE at setup time and keep the returned
|
||||
# OperatorProperties handles. target_set_operator allocates a fresh handle on
|
||||
# every call, so calling it from position_gizmos (which fires every redraw
|
||||
@@ -3911,11 +4085,11 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
||||
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.unjoin_wall_path_connection"
|
||||
"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.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:
|
||||
# Default: hide every pool slot. The visible-set is rebuilt from the live
|
||||
@@ -3936,15 +4110,28 @@ 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
|
||||
self_is_fillet = tool.Parametric.is_fillet_corner_wall(elem)
|
||||
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)
|
||||
@@ -3955,20 +4142,224 @@ 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)
|
||||
is_locked = self_is_fillet or tool.Parametric.is_fillet_corner_wall(other_elem)
|
||||
self._bind_unjoin_icon(
|
||||
slot_idx, location + clearance, billboard_rot, elem, other_elem, other_obj, is_locked=is_locked
|
||||
)
|
||||
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, *, is_locked=False
|
||||
):
|
||||
"""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.
|
||||
|
||||
``is_locked=True`` (fillet corner involvement) writes a muted color
|
||||
instead of the active tone; the GUIDs still propagate so the bound
|
||||
operator can surface a friendly INFO report on click."""
|
||||
icon = self.unjoin_icons[slot_idx]
|
||||
icon.matrix_basis = gizmo.billboarded_at(location, billboard_rot, scale=self.ICON_SCALE)
|
||||
icon.hide = False
|
||||
icon.color = self.LOCKED_COLOR if is_locked else self._default_unjoin_color
|
||||
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 partner-GlobalId property is 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].other_wall_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(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):
|
||||
|
||||
@@ -963,7 +963,11 @@ class EditObjectUI:
|
||||
|
||||
@classmethod
|
||||
def draw_regen_operations(cls, row, ui_context):
|
||||
if AuthoringData.data["is_regenable_element"]:
|
||||
# ``AuthoringData.load`` flips ``is_loaded`` at entry as a recursion
|
||||
# guard, so a partial load (any computation along the way raising)
|
||||
# leaves the tail keys unset. ``.get()`` keeps the header draw alive
|
||||
# until the underlying failure is investigated.
|
||||
if AuthoringData.data.get("is_regenable_element"):
|
||||
row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row
|
||||
add_layout_hotkey_operator(row, "Regen", "S_G", "Recalculate Element Geometry", ui_context)
|
||||
|
||||
@@ -1317,7 +1321,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.recalculate_profile()
|
||||
elif self.active_class in ("IfcWindow", "IfcWindowStandardCase", "IfcDoor", "IfcDoorStandardCase"):
|
||||
bpy.ops.bim.recalculate_fill()
|
||||
elif self.active_class in ("IfcSpace"):
|
||||
elif self.active_class in ("IfcSpace",):
|
||||
bpy.ops.bim.generate_space()
|
||||
|
||||
def hotkey_S_M(self):
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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.
|
||||
|
||||
"""Shared post-disconnect cleanup dispatch.
|
||||
|
||||
Used by both ``bim.disconnect_elements`` (explicit user disconnect) and the
|
||||
connection cascade in ``tool.Geometry.delete_ifc_object`` (implicit
|
||||
disconnect-on-delete). Each rel kind returned by
|
||||
:py:meth:`bonsai.tool.connection.Connection.find_rels` /
|
||||
:py:meth:`find_rels_for_element` maps to a single arm here, so adding a new
|
||||
rel kind means extending one dispatch table — both call sites benefit
|
||||
automatically and the AST forward-compat guard enforces coverage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bonsai.core.geometry
|
||||
from bonsai.core.model import regenerate_wall_to_underside
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
|
||||
def disconnect_rel(
|
||||
ifc: "type[tool.Ifc]",
|
||||
geometry: "type[tool.Geometry]",
|
||||
model: "type[tool.Model]",
|
||||
connection: "type[tool.Connection]",
|
||||
rel: "ifcopenshell.entity_instance",
|
||||
kind: str,
|
||||
elem: "ifcopenshell.entity_instance",
|
||||
partner: "ifcopenshell.entity_instance",
|
||||
skip_elem_recreate: bool = False,
|
||||
skip_partner_recreate: bool = False,
|
||||
) -> None:
|
||||
"""Run the post-disconnect cleanup for one rel.
|
||||
|
||||
``elem`` and ``partner`` are the two endpoints. The ``skip_*_recreate``
|
||||
flags suppress per-side regenerate / recreate work — used by the
|
||||
cascade-on-delete to avoid re-extruding entities that are about to be
|
||||
removed by ``remove_product``. For the disconnect operator (where neither
|
||||
endpoint is being deleted), both flags stay False and the full cleanup
|
||||
runs on both sides.
|
||||
"""
|
||||
if kind == "path":
|
||||
bonsai.core.geometry.remove_connection(geometry, connection=rel)
|
||||
if not skip_elem_recreate:
|
||||
elem_obj = ifc.get_object(elem)
|
||||
if elem_obj is not None:
|
||||
model.recreate_wall(elem, elem_obj)
|
||||
if not skip_partner_recreate:
|
||||
partner_obj = ifc.get_object(partner)
|
||||
if partner_obj is not None:
|
||||
model.recreate_wall(partner, partner_obj)
|
||||
elif kind == "element-top":
|
||||
wall, _slab = connection.orient_element_top(rel, elem, partner)
|
||||
ifc.run(
|
||||
"geometry.disconnect_element",
|
||||
relating_element=rel.RelatingElement,
|
||||
related_element=rel.RelatedElement,
|
||||
)
|
||||
# Skip the wall-side regenerate when the wall is itself being deleted —
|
||||
# either it's the elem of this cascade pass, or it's the partner that
|
||||
# was queued earlier in the same batch.
|
||||
if (wall is elem and skip_elem_recreate) or (wall is partner and skip_partner_recreate):
|
||||
return
|
||||
wall_obj = ifc.get_object(wall)
|
||||
if wall_obj is not None:
|
||||
regenerate_wall_to_underside(ifc, geometry, model, [wall_obj])
|
||||
elif kind == "element":
|
||||
ifc.run(
|
||||
"geometry.disconnect_element",
|
||||
relating_element=rel.RelatingElement,
|
||||
related_element=rel.RelatedElement,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown rel kind: {kind!r}")
|
||||
@@ -167,12 +167,22 @@ def regenerate_wall_to_underside(
|
||||
model: type[tool.Model],
|
||||
wall_objs: list[bpy.types.Object],
|
||||
) -> None:
|
||||
"""Re-clip walls to their connected underside objects after the slab has moved."""
|
||||
"""Re-clip walls to their connected underside objects after the slab has moved.
|
||||
|
||||
When a wall has no remaining slab connections — the case reached after the
|
||||
last TOP rel is severed (via disconnect or via cascade-on-slab-delete) — the
|
||||
stale trim booleans are cleaned up so the wall reverts to its pre-clip
|
||||
extrusion instead of holding orphan ``IfcBooleanResult`` items and a dead
|
||||
``BBIM_Boolean`` pset.
|
||||
"""
|
||||
clipped_objs = []
|
||||
reverted_objs = []
|
||||
for obj in wall_objs:
|
||||
wall = ifc.get_entity(obj)
|
||||
slab_objs = model.get_connected_slab_objs(wall)
|
||||
if not slab_objs:
|
||||
model.remove_wall_to_underside_booleans(wall)
|
||||
reverted_objs.append(obj)
|
||||
continue
|
||||
if ifc.is_moved(obj):
|
||||
geometry.run_edit_object_placement(obj=obj)
|
||||
@@ -185,8 +195,9 @@ def regenerate_wall_to_underside(
|
||||
if clip:
|
||||
model.clip_wall_to_slab(wall, clip)
|
||||
clipped_objs.append(obj)
|
||||
if clipped_objs:
|
||||
model.reload_body_representation(clipped_objs)
|
||||
refresh_objs = clipped_objs + reverted_objs
|
||||
if refresh_objs:
|
||||
model.reload_body_representation(refresh_objs)
|
||||
|
||||
|
||||
def extend_wall_to_slab(
|
||||
|
||||
@@ -195,6 +195,14 @@ class Collector:
|
||||
def assign(cls, obj, should_clean_users_collection=False): pass
|
||||
|
||||
|
||||
@interface
|
||||
class Connection:
|
||||
def find_rel(cls, elem_a, elem_b): pass
|
||||
def find_rels(cls, elem_a, elem_b): pass
|
||||
def find_rels_for_element(cls, elem): pass
|
||||
def orient_element_top(cls, rel, elem_a, elem_b): pass
|
||||
|
||||
|
||||
@interface
|
||||
class Context:
|
||||
def clear_context(cls): pass
|
||||
@@ -694,11 +702,13 @@ class Model:
|
||||
def load_openings(cls, openings): pass
|
||||
def purge_scene_openings(cls): pass
|
||||
def recalculate_walls(cls, objs): pass
|
||||
def recreate_wall(cls, element, obj): pass
|
||||
def regenerate_array(cls, parent, data): pass
|
||||
def regenerate_profile(cls, obj): pass
|
||||
def regenerate_slab(cls, obj): pass
|
||||
def reload_body_representation(cls, obj_or_objects): pass
|
||||
def remove_wall_to_underside_booleans(cls, wall): pass
|
||||
def strip_underside_booleans(cls, wall): pass
|
||||
def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from bonsai.tool.cad import Cad
|
||||
from bonsai.tool.clash import Clash
|
||||
from bonsai.tool.classification import Classification
|
||||
from bonsai.tool.collector import Collector
|
||||
from bonsai.tool.connection import Connection
|
||||
from bonsai.tool.context import Context
|
||||
from bonsai.tool.cost import Cost
|
||||
from bonsai.tool.covering import Covering
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# 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 find_rels_for_element(
|
||||
cls,
|
||||
elem: "ifcopenshell.entity_instance",
|
||||
) -> "list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]":
|
||||
"""Return every supported rel touching ``elem`` as ``(rel, kind, partner)``
|
||||
triples. ``partner`` is the *other* element on the rel — the side cascade
|
||||
cleanup must operate on when ``elem`` is being deleted.
|
||||
|
||||
Mirrors :py:meth:`find_rels`'s kind taxonomy. The single-element entry
|
||||
point lets the cascade-on-delete in ``tool.Geometry.delete_ifc_object``
|
||||
enumerate everything the disconnect operator would handle pairwise.
|
||||
"""
|
||||
result: list[tuple["ifcopenshell.entity_instance", str, "ifcopenshell.entity_instance"]] = []
|
||||
seen: set[int] = set()
|
||||
|
||||
def _record(rel, kind, partner):
|
||||
if partner is None or rel.id() in seen:
|
||||
return
|
||||
seen.add(rel.id())
|
||||
result.append((rel, kind, partner))
|
||||
|
||||
for rel in getattr(elem, "ConnectedTo", []) or ():
|
||||
if rel.is_a("IfcRelConnectsPathElements"):
|
||||
_record(rel, "path", getattr(rel, "RelatedElement", None))
|
||||
elif rel.is_a("IfcRelConnectsElements"):
|
||||
kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
|
||||
_record(rel, kind, getattr(rel, "RelatedElement", None))
|
||||
for rel in getattr(elem, "ConnectedFrom", []) or ():
|
||||
if rel.is_a("IfcRelConnectsPathElements"):
|
||||
_record(rel, "path", getattr(rel, "RelatingElement", None))
|
||||
elif rel.is_a("IfcRelConnectsElements"):
|
||||
kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element"
|
||||
_record(rel, kind, getattr(rel, "RelatingElement", None))
|
||||
|
||||
return result
|
||||
|
||||
@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
|
||||
@@ -65,6 +65,7 @@ from typing_extensions import TypeIs
|
||||
|
||||
import bonsai.bim.helper
|
||||
import bonsai.bim.import_ifc
|
||||
import bonsai.core.connection
|
||||
import bonsai.core.drawing
|
||||
import bonsai.core.geometry
|
||||
import bonsai.core.root
|
||||
@@ -271,12 +272,38 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
bpy.data.objects.remove(obj)
|
||||
|
||||
@classmethod
|
||||
def delete_ifc_object(cls, obj: bpy.types.Object) -> None:
|
||||
def delete_ifc_object(
|
||||
cls,
|
||||
obj: bpy.types.Object,
|
||||
batch_being_deleted_ids: Optional[set[int]] = None,
|
||||
) -> None:
|
||||
ifc_file = tool.Ifc.get()
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return
|
||||
elif element.is_a("IfcAnnotation"):
|
||||
# Cascade connection-rel teardown — symmetric to bim.disconnect_elements.
|
||||
# When a slab connected to a wall via IfcRelConnectsElements(TOP) is deleted,
|
||||
# the wall's trim booleans + BBIM_Boolean pset would otherwise be orphaned.
|
||||
# skip_elem_recreate is always True here because we're inside delete: the
|
||||
# element is about to vanish, so re-extruding it would be wasted work.
|
||||
# skip_partner_recreate fires only when the partner is also queued in the
|
||||
# same OverrideDelete batch.
|
||||
if element.is_a("IfcRoot"):
|
||||
skip_ids = batch_being_deleted_ids or set()
|
||||
for rel, kind, partner in tool.Connection.find_rels_for_element(element):
|
||||
bonsai.core.connection.disconnect_rel(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
tool.Model,
|
||||
tool.Connection,
|
||||
rel=rel,
|
||||
kind=kind,
|
||||
elem=element,
|
||||
partner=partner,
|
||||
skip_elem_recreate=True,
|
||||
skip_partner_recreate=(partner.id() in skip_ids),
|
||||
)
|
||||
if element.is_a("IfcAnnotation"):
|
||||
if element.ObjectType == "DRAWING":
|
||||
return bonsai.core.drawing.remove_drawing(tool.Ifc, tool.Drawing, drawing=element)
|
||||
elif tool.Drawing.is_auto_annotation(element):
|
||||
@@ -641,7 +668,13 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
and isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES)
|
||||
and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id)
|
||||
):
|
||||
return tool.Ifc.get().by_id(ifc_id)
|
||||
try:
|
||||
return tool.Ifc.get().by_id(ifc_id)
|
||||
except RuntimeError:
|
||||
# Stale id: a representation rebuild freed the old entity
|
||||
# while obj.data still tracks its id. Treated as "no active
|
||||
# representation" — same contract as a mesh with id 0.
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_data_representation(cls, data: bpy.types.ID) -> ifcopenshell.entity_instance | None:
|
||||
@@ -2348,6 +2381,21 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
old_to_new[element] = [new]
|
||||
if new.is_a("IfcRelSpaceBoundary"):
|
||||
tool.Boundary.decorate_boundary(new_obj)
|
||||
# Slab-trim booleans (from extend_walls_to_underside) belong to
|
||||
# the source wall's connection, not the copy. Strip them so the
|
||||
# duplicate reverts to its pre-clip extrusion — mirrors the way
|
||||
# filling rels are dropped while manual booleans persist on copy.
|
||||
# Reload the body when something was stripped so the viewport
|
||||
# immediately shows the unclipped geometry; otherwise the user
|
||||
# sees a stale mesh until they Shift+G, which is easy to miss.
|
||||
if new.is_a("IfcWall"):
|
||||
if tool.Model.strip_underside_booleans(new):
|
||||
tool.Model.reload_body_representation(new_obj)
|
||||
# HasOpenings rels don't follow object duplication, so
|
||||
# the duplicate's body must rebuild to match its current
|
||||
# opening set.
|
||||
else:
|
||||
tool.Model.regenerate_wall(new_obj)
|
||||
|
||||
# Remap Blender parent relationships for duplicated objects
|
||||
for old_obj_name, new_obj_name in old_obj_name_to_new_obj_name.items():
|
||||
|
||||
@@ -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]
|
||||
@@ -904,6 +909,46 @@ class Model(bonsai.core.tool.Model):
|
||||
"""Return True if element has an IfcRelConnectsElements(TOP) relationship."""
|
||||
return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom)
|
||||
|
||||
@classmethod
|
||||
def strip_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> bool:
|
||||
"""Remove slab-trim ``IfcBooleanResult`` items from a wall's body chain.
|
||||
|
||||
Returns ``True`` if any boolean was removed, so the caller knows whether
|
||||
a Blender-side body reload is needed to surface the geometry change.
|
||||
|
||||
Hook for the duplicate path (Shift+D): the source wall's clip booleans
|
||||
don't make sense on a copy pulled away from the slab. Booleans whose
|
||||
``SecondOperand.is_a("IfcTessellatedFaceSet")`` are removed — same
|
||||
imprecise discriminator the rest of the wall-to-underside machinery
|
||||
uses (manual cuts authored from tessellated meshes would also be
|
||||
stripped, but most manual cuts use ``IfcExtrudedAreaSolid`` / CSG
|
||||
primitives and are unaffected).
|
||||
|
||||
Cannot reuse ``remove_wall_to_underside_booleans`` here because the
|
||||
duplicate's ``BBIM_Boolean.Data`` holds the source wall's stale ids —
|
||||
``get_manual_booleans`` returns empty on the copy and the helper
|
||||
early-returns. The duplicate hook works directly off the chain.
|
||||
"""
|
||||
representation = tool.Geometry.get_body_representation(wall)
|
||||
if not representation:
|
||||
return False
|
||||
chain = cls.get_booleans(wall, representation)
|
||||
to_remove = [b for b in chain if (sec := b.SecondOperand) is not None and sec.is_a("IfcTessellatedFaceSet")]
|
||||
for b in to_remove:
|
||||
tool.Geometry.remove_representation_item(b.SecondOperand, wall)
|
||||
# Sweep the now-stale BBIM_Boolean entries on the copy (their ids point
|
||||
# at booleans that were never in this wall's chain — they survived the
|
||||
# ifcopenshell deep copy as JSON text in the pset payload).
|
||||
pset_data = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean")
|
||||
if pset_data:
|
||||
representation = tool.Geometry.get_body_representation(wall)
|
||||
chain_ids = {b.id() for b in cls.get_booleans(wall, representation)} if representation else set()
|
||||
stored_ids = set(json.loads(pset_data["Data"]))
|
||||
stale_ids = stored_ids - chain_ids
|
||||
if stale_ids:
|
||||
cls.unmark_manual_booleans(wall, list(stale_ids))
|
||||
return bool(to_remove)
|
||||
|
||||
@classmethod
|
||||
def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None:
|
||||
"""Remove all IfcBooleanResult items previously added by extend_walls_to_underside."""
|
||||
@@ -3024,6 +3069,19 @@ class Model(bonsai.core.tool.Model):
|
||||
obj.matrix_world = tool.Loader.apply_blender_offset_to_matrix_world(obj, matrix)
|
||||
tool.Geometry.record_object_position(obj)
|
||||
|
||||
@classmethod
|
||||
def regenerate_wall(cls, obj: bpy.types.Object) -> None:
|
||||
"""Rebuild a wall's body from current IFC state: extrusion + openings
|
||||
first, then re-clip to any surviving ``IfcRelConnectsElements(TOP)``
|
||||
slab. Safe on walls with no openings and no slab connection — both
|
||||
steps no-op against their preconditions."""
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None:
|
||||
return
|
||||
cls.recreate_wall(element, obj)
|
||||
if cls.has_underside_connection(element):
|
||||
bonsai.core.model.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, cls, [obj])
|
||||
|
||||
@classmethod
|
||||
def recalculate_walls(cls, walls: list[bpy.types.Object]) -> None:
|
||||
queue: set[tuple[ifcopenshell.entity_instance, bpy.types.Object]] = set()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -242,6 +242,75 @@ class Wall(bonsai.core.tool.Wall):
|
||||
local_p2 = Vector((p2[0] * unit_scale, p2[1] * unit_scale, 0.0))
|
||||
return obj.matrix_world @ local_p1, obj.matrix_world @ local_p2
|
||||
|
||||
@classmethod
|
||||
def iter_wall_slab_connections(cls, wall: ifcopenshell.entity_instance):
|
||||
"""Yield ``(slab, rel)`` tuples for every ``IfcRelConnectsElements(TOP)``
|
||||
connecting a slab to this wall — the rel kind ``extend_walls_to_underside``
|
||||
creates. Walks ``wall.ConnectedFrom`` because the slab is the relating
|
||||
side of the TOP rel."""
|
||||
for rel in getattr(wall, "ConnectedFrom", []) or ():
|
||||
if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP":
|
||||
continue
|
||||
slab = rel.RelatingElement
|
||||
if slab is None:
|
||||
continue
|
||||
yield slab, rel
|
||||
|
||||
@classmethod
|
||||
def iter_slab_wall_connections(cls, slab: ifcopenshell.entity_instance):
|
||||
"""Yield ``(wall, rel)`` tuples for every wall clipped to this slab's
|
||||
underside. Mirror of ``iter_wall_slab_connections`` from the slab side
|
||||
— walks ``slab.ConnectedTo``."""
|
||||
for rel in getattr(slab, "ConnectedTo", []) or ():
|
||||
if not rel.is_a("IfcRelConnectsElements") or rel.Description != "TOP":
|
||||
continue
|
||||
wall = rel.RelatedElement
|
||||
if wall is None:
|
||||
continue
|
||||
yield wall, rel
|
||||
|
||||
@classmethod
|
||||
def find_wall_slab_rel(
|
||||
cls, wall: ifcopenshell.entity_instance, slab: ifcopenshell.entity_instance
|
||||
) -> ifcopenshell.entity_instance | None:
|
||||
"""Return the single ``IfcRelConnectsElements(TOP)`` between ``wall``
|
||||
and ``slab``, or ``None`` if none exists. Used by the disconnect
|
||||
operator to find the specific rel to remove."""
|
||||
for s, rel in cls.iter_wall_slab_connections(wall):
|
||||
if s == slab:
|
||||
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 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
|
||||
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(
|
||||
cls,
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
# 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_rels_for_element — single-element entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_find_rels_for_element_returns_kind_and_partner_per_rel():
|
||||
"""Cascade-on-delete needs every rel touching one element plus the partner
|
||||
element on the other side of each rel — that's the cleanup target."""
|
||||
elem = _elem()
|
||||
partner_a = _elem()
|
||||
partner_b = _elem()
|
||||
rel_path = _rel("IfcRelConnectsPathElements", related=partner_a, rel_id=1)
|
||||
rel_top = _rel("IfcRelConnectsElements", relating=partner_b, description="TOP", rel_id=2)
|
||||
elem.ConnectedTo = [rel_path]
|
||||
elem.ConnectedFrom = [rel_top]
|
||||
|
||||
result = tool.Connection.find_rels_for_element(elem)
|
||||
|
||||
assert (rel_path, "path", partner_a) in result
|
||||
assert (rel_top, "element-top", partner_b) in result
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
def test_find_rels_for_element_dedups_by_rel_id():
|
||||
elem = _elem()
|
||||
partner = _elem()
|
||||
rel = _rel("IfcRelConnectsPathElements", related=partner, relating=partner, rel_id=1)
|
||||
elem.ConnectedTo = [rel]
|
||||
elem.ConnectedFrom = [rel]
|
||||
|
||||
result = tool.Connection.find_rels_for_element(elem)
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_find_rels_for_element_skips_rels_without_partner():
|
||||
"""Defensive: a malformed rel missing the opposite-side attribute should not
|
||||
crash — record nothing for it rather than emit a (rel, kind, None) triple
|
||||
that would later trip a None-deref in the dispatch."""
|
||||
elem = _elem()
|
||||
bad = _rel("IfcRelConnectsPathElements", related=None, rel_id=1)
|
||||
elem.ConnectedTo = [bad]
|
||||
|
||||
assert tool.Connection.find_rels_for_element(elem) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_dispatches_one_call_per_rel():
|
||||
"""Operator forwards every rel returned by find_rels to disconnect_rel,
|
||||
in order — the operator is a thin wrapper; per-kind cleanup logic lives
|
||||
in core.connection.disconnect_rel and is tested separately."""
|
||||
from bonsai.bim.module.model.wall import DisconnectElements
|
||||
|
||||
elem_a = Mock()
|
||||
elem_b = Mock()
|
||||
rel1 = Mock()
|
||||
rel2 = 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, "element-top")],
|
||||
), patch("bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel") as dispatch, patch(
|
||||
"bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()
|
||||
), patch(
|
||||
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
|
||||
), patch(
|
||||
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
|
||||
):
|
||||
DisconnectElements._perform(op, context=MagicMock())
|
||||
|
||||
assert dispatch.call_count == 2
|
||||
# Both rels dispatch with elem=elem_a, partner=elem_b regardless of orientation
|
||||
# — orient_element_top inside disconnect_rel recovers the wall/slab roles.
|
||||
for call, expected_rel, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]):
|
||||
kw = call.kwargs
|
||||
assert kw["rel"] is expected_rel
|
||||
assert kw["kind"] == expected_kind
|
||||
assert kw["elem"] is elem_a
|
||||
assert kw["partner"] is elem_b
|
||||
op.report.assert_not_called()
|
||||
|
||||
|
||||
def test_disconnect_resyncs_path_objs_once_for_path_kind():
|
||||
"""For path rels the operator collects both endpoint objects and resyncs
|
||||
drafts once at the end — a Blender-side concern that doesn't belong in
|
||||
the core dispatch."""
|
||||
from bonsai.bim.module.model.wall import DisconnectElements
|
||||
|
||||
elem_a = Mock()
|
||||
elem_b = Mock()
|
||||
obj_a = Mock()
|
||||
obj_b = Mock()
|
||||
rel = 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=[(rel, "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.connection.disconnect_rel"
|
||||
), patch(
|
||||
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
|
||||
) as resync, patch(
|
||||
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
|
||||
):
|
||||
DisconnectElements._perform(op, context=MagicMock())
|
||||
|
||||
resync.assert_called_once_with([obj_a, obj_b])
|
||||
|
||||
|
||||
def test_disconnect_skips_resync_for_non_path_kind():
|
||||
"""element-top / element kinds don't need wall-draft resync — that's a
|
||||
path-specific concern (DumbWallJoiner geometry refresh)."""
|
||||
from bonsai.bim.module.model.wall import DisconnectElements
|
||||
|
||||
elem_a = Mock()
|
||||
elem_b = Mock()
|
||||
rel = 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=[(rel, "element-top")]
|
||||
), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch(
|
||||
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
|
||||
), patch(
|
||||
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
|
||||
) as resync, patch(
|
||||
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
|
||||
):
|
||||
DisconnectElements._perform(op, context=MagicMock())
|
||||
|
||||
resync.assert_not_called()
|
||||
|
||||
|
||||
def test_disconnect_gizmo_direction_symmetry():
|
||||
"""The wall-selected gizmo dispatches with element_a=wall, element_b=slab.
|
||||
The slab-selected gizmo dispatches with element_a=slab, element_b=wall.
|
||||
Both routes hit disconnect_rel with the same (rel, kind) pair — orientation
|
||||
recovery happens inside the dispatch, not at the operator layer."""
|
||||
from bonsai.bim.module.model.wall import DisconnectElements
|
||||
|
||||
wall = Mock(name="wall")
|
||||
slab = Mock(name="slab")
|
||||
rel = Mock()
|
||||
|
||||
ifc_file = MagicMock()
|
||||
op = _make_op()
|
||||
|
||||
def _run_with_guids(a, b):
|
||||
ifc_file.by_guid.side_effect = lambda g: {a: wall if a == "WALL" else slab, b: slab if b == "SLAB" else wall}[g]
|
||||
op.element_a_guid = a
|
||||
op.element_b_guid = 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", return_value=[(rel, "element-top")]
|
||||
), patch("bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()), patch(
|
||||
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
|
||||
) as dispatch, patch(
|
||||
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
|
||||
), patch(
|
||||
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall", return_value=False
|
||||
):
|
||||
DisconnectElements._perform(op, context=MagicMock())
|
||||
return dispatch.call_args.kwargs
|
||||
|
||||
wall_first = _run_with_guids("WALL", "SLAB")
|
||||
slab_first = _run_with_guids("SLAB", "WALL")
|
||||
|
||||
# disconnect_rel sees (rel, "element-top") in both runs; elem/partner swap
|
||||
# by argument order but orient_element_top inside disconnect_rel resolves
|
||||
# the wall/slab roles symmetrically.
|
||||
assert wall_first["rel"] is rel and slab_first["rel"] is rel
|
||||
assert wall_first["kind"] == slab_first["kind"] == "element-top"
|
||||
assert {wall_first["elem"], wall_first["partner"]} == {wall, slab}
|
||||
assert {slab_first["elem"], slab_first["partner"]} == {wall, slab}
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_disconnect_refuses_path_kind_when_either_side_is_fillet():
|
||||
"""The fillet corner's join with its source walls defines its identity
|
||||
— unjoining there would tear down the chord axis reference. The
|
||||
operator reports an INFO directing the user to delete the corner
|
||||
wall and skips the dispatch entirely."""
|
||||
from bonsai.bim.module.model.wall import DisconnectElements
|
||||
|
||||
fillet = Mock(name="fillet_corner")
|
||||
wall = Mock(name="source_wall")
|
||||
rel = Mock()
|
||||
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_guid.side_effect = lambda g: {"A": fillet, "B": wall}[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, "path")]
|
||||
), patch(
|
||||
"bonsai.bim.module.model.wall.tool.Parametric.is_fillet_corner_wall",
|
||||
side_effect=lambda e: e is fillet,
|
||||
), patch(
|
||||
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
|
||||
) as dispatch:
|
||||
DisconnectElements._perform(op, context=MagicMock())
|
||||
|
||||
dispatch.assert_not_called()
|
||||
op.report.assert_called_once()
|
||||
args, _ = op.report.call_args
|
||||
assert args[0] == {"INFO"}
|
||||
|
||||
|
||||
def test_disconnect_allows_slab_kind_even_when_wall_is_fillet():
|
||||
"""The fillet ↔ slab underside clip is a different relationship from
|
||||
the fillet ↔ source-wall path join. Slab disconnect must remain
|
||||
available while the corner is in preview."""
|
||||
from bonsai.bim.module.model.wall import DisconnectElements
|
||||
|
||||
fillet = Mock(name="fillet_corner")
|
||||
slab = Mock(name="slab")
|
||||
rel = Mock()
|
||||
|
||||
ifc_file = MagicMock()
|
||||
ifc_file.by_guid.side_effect = lambda g: {"A": fillet, "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.Parametric.is_fillet_corner_wall",
|
||||
side_effect=lambda e: e is fillet,
|
||||
), patch(
|
||||
"bonsai.bim.module.model.wall.tool.Ifc.get_object", return_value=Mock()
|
||||
), patch(
|
||||
"bonsai.bim.module.model.wall.bonsai.core.connection.disconnect_rel"
|
||||
) as dispatch, patch(
|
||||
"bonsai.bim.module.model.wall._resync_walls_after_mutation"
|
||||
):
|
||||
DisconnectElements._perform(op, context=MagicMock())
|
||||
|
||||
dispatch.assert_called_once()
|
||||
@@ -0,0 +1,87 @@
|
||||
# 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.
|
||||
|
||||
"""Pins the active-is-survivor merge convention.
|
||||
|
||||
``bim.merge_wall`` must consume the non-active selection into the active
|
||||
one — matching Blender's ``OBJECT_OT_join`` / ``MESH_OT_merge`` "at
|
||||
last" convention. Users following Ctrl+J muscle-memory click the
|
||||
surviving wall last; the operator must align with that expectation."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _run_perform(active, other):
|
||||
"""Invoke ``MergeWall._perform`` as an unbound function with the
|
||||
two wall stubs in the selection, patching the heavy IFC / Blender
|
||||
side effects. Returns the ``(merger_arg_1, merger_arg_2)`` actually
|
||||
passed to ``DumbWallJoiner.merge``."""
|
||||
from bonsai.bim.module.model.wall import MergeWall
|
||||
|
||||
context = SimpleNamespace(active_object=active)
|
||||
captured_call = {}
|
||||
|
||||
def _capture_merge(self, a, b):
|
||||
captured_call["wall1"] = a
|
||||
captured_call["wall2"] = b
|
||||
|
||||
fake_self = SimpleNamespace()
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
|
||||
patch("bonsai.bim.module.model.wall.tool.Model.get_selected_mesh_objects", return_value=[active, other]),
|
||||
patch("bonsai.bim.module.model.wall.DumbWallJoiner.__init__", return_value=None),
|
||||
patch("bonsai.bim.module.model.wall.DumbWallJoiner.merge", new=_capture_merge),
|
||||
patch("bonsai.bim.module.model.wall._maybe_resync_wall_props_from_ifc"),
|
||||
patch("bonsai.bim.module.model.wall._regenerate_walls") as regen_walls,
|
||||
):
|
||||
result = MergeWall._perform(fake_self, context)
|
||||
|
||||
return captured_call, regen_walls, result
|
||||
|
||||
|
||||
def test_active_wall_is_passed_as_survivor_to_merge():
|
||||
"""The first argument to ``DumbWallJoiner.merge`` is the survivor;
|
||||
the active object must occupy that slot so the wall the user clicked
|
||||
last absorbs the other."""
|
||||
active = SimpleNamespace(name="active")
|
||||
other = SimpleNamespace(name="other")
|
||||
|
||||
captured, _regen, _ = _run_perform(active, other)
|
||||
|
||||
assert captured["wall1"] is active
|
||||
assert captured["wall2"] is other
|
||||
|
||||
|
||||
def test_post_merge_resync_targets_active_not_consumed():
|
||||
"""After the merge ``_regenerate_walls`` rebuilds the survivor's
|
||||
body. Targeting the consumed wall would crash on a freed ``bpy_struct``;
|
||||
the survivor (active) is the only valid target."""
|
||||
active = SimpleNamespace(name="active")
|
||||
other = SimpleNamespace(name="other")
|
||||
|
||||
_, regen_walls, _ = _run_perform(active, other)
|
||||
|
||||
regen_walls.assert_called_once_with([active])
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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.
|
||||
|
||||
"""Pins the branching contract of ``tool.Model.regenerate_wall``.
|
||||
|
||||
The body rebuild always runs (extrusion + openings); the slab re-clip only
|
||||
runs when an ``IfcRelConnectsElements(TOP)`` rel survives. A wall without
|
||||
either feature still completes without crashing."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def test_regenerate_wall_rebuilds_body_and_reclips_when_connected():
|
||||
"""Wall with a TOP connection: body rebuilt first, then re-clipped."""
|
||||
element = Mock()
|
||||
obj = Mock()
|
||||
|
||||
with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object(
|
||||
tool.Model, "recreate_wall"
|
||||
) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=True), patch(
|
||||
"bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
|
||||
) as regen:
|
||||
tool.Model.regenerate_wall(obj)
|
||||
|
||||
recreate.assert_called_once_with(element, obj)
|
||||
regen.assert_called_once()
|
||||
args, _ = regen.call_args
|
||||
assert args[3] == [obj]
|
||||
|
||||
|
||||
def test_regenerate_wall_skips_reclip_when_no_top_rel():
|
||||
"""Wall without a TOP connection: body rebuilt; re-clip skipped."""
|
||||
element = Mock()
|
||||
obj = Mock()
|
||||
|
||||
with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=element), patch.object(
|
||||
tool.Model, "recreate_wall"
|
||||
) as recreate, patch.object(tool.Model, "has_underside_connection", return_value=False), patch(
|
||||
"bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
|
||||
) as regen:
|
||||
tool.Model.regenerate_wall(obj)
|
||||
|
||||
recreate.assert_called_once_with(element, obj)
|
||||
regen.assert_not_called()
|
||||
|
||||
|
||||
def test_regenerate_wall_noops_when_obj_has_no_ifc_entity():
|
||||
"""Non-IFC objects (e.g. a freshly created Blender mesh before
|
||||
`tool.Ifc.run("root.create_entity")` runs) return None from get_entity;
|
||||
the helper must return without touching the body or any rels."""
|
||||
obj = Mock()
|
||||
|
||||
with patch("bonsai.tool.model.tool.Ifc.get_entity", return_value=None), patch.object(
|
||||
tool.Model, "recreate_wall"
|
||||
) as recreate, patch.object(tool.Model, "has_underside_connection") as has_top, patch(
|
||||
"bonsai.tool.model.bonsai.core.model.regenerate_wall_to_underside"
|
||||
) as regen:
|
||||
tool.Model.regenerate_wall(obj)
|
||||
|
||||
recreate.assert_not_called()
|
||||
has_top.assert_not_called()
|
||||
regen.assert_not_called()
|
||||
@@ -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():
|
||||
|
||||
@@ -50,12 +50,15 @@ def _make_context(active, selected):
|
||||
return SimpleNamespace(active_object=active, selected_objects=list(selected))
|
||||
|
||||
|
||||
def _patch_tools(prefs_on, selected, active_element, other_element, active_usage, other_usage):
|
||||
def _patch_tools(
|
||||
prefs_on, selected, active_element, other_element, active_usage, other_usage, other_is_path_connectable=None
|
||||
):
|
||||
"""Return a stack of patches that simulate one selection / IFC state for poll().
|
||||
|
||||
``prefs.gizmos.draw_gizmos_in_3d_viewport`` is the top-level toggle. The
|
||||
selection set, the IFC entity lookup, and the usage-type lookup are stubbed
|
||||
so the test only depends on the predicate ordering in poll()."""
|
||||
selection set, the IFC entity lookup, the usage-type lookup, and the
|
||||
path-connectable-wall predicate are stubbed so the test only depends on
|
||||
the predicate ordering in poll()."""
|
||||
prefs = SimpleNamespace(gizmos=SimpleNamespace(draw_gizmos_in_3d_viewport=prefs_on))
|
||||
|
||||
entity_map = {}
|
||||
@@ -67,12 +70,18 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
|
||||
usage_map[id(active_element)] = active_usage
|
||||
usage_map[id(other_element)] = other_usage
|
||||
|
||||
if other_is_path_connectable is None:
|
||||
other_is_path_connectable = other_usage == "LAYER2"
|
||||
|
||||
def get_entity(obj):
|
||||
return entity_map.get(id(obj))
|
||||
|
||||
def get_usage_type(element):
|
||||
return usage_map.get(id(element))
|
||||
|
||||
def is_path_connectable_wall(element):
|
||||
return element is other_element and other_is_path_connectable
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
return [
|
||||
@@ -80,6 +89,7 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
|
||||
patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)),
|
||||
patch.object(tool.Ifc, "get_entity", side_effect=get_entity),
|
||||
patch.object(tool.Model, "get_usage_type", side_effect=get_usage_type),
|
||||
patch.object(tool.Parametric, "is_path_connectable_wall", side_effect=is_path_connectable_wall),
|
||||
# The array-child filter is pinned by its own test file; stub it here
|
||||
# so these poll tests stay focused on the count / layer-usage gates
|
||||
# and don't have to scaffold the memoization cache key.
|
||||
@@ -87,7 +97,15 @@ def _patch_tools(prefs_on, selected, active_element, other_element, active_usage
|
||||
]
|
||||
|
||||
|
||||
def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other_usage, active_has_entity=True):
|
||||
def _run_poll(
|
||||
prefs_on,
|
||||
active_is_in_selected,
|
||||
len_override,
|
||||
active_usage,
|
||||
other_usage,
|
||||
active_has_entity=True,
|
||||
other_is_path_connectable=None,
|
||||
):
|
||||
from bonsai.bim.module.model.wall import GizmoWallExtendVertically
|
||||
|
||||
slab_obj = _Obj("slab")
|
||||
@@ -103,7 +121,15 @@ def _run_poll(prefs_on, active_is_in_selected, len_override, active_usage, other
|
||||
slab_element = object() if active_has_entity else None
|
||||
wall_element = object()
|
||||
|
||||
patches = _patch_tools(prefs_on, selected, slab_element, wall_element, active_usage, other_usage)
|
||||
patches = _patch_tools(
|
||||
prefs_on,
|
||||
selected,
|
||||
slab_element,
|
||||
wall_element,
|
||||
active_usage,
|
||||
other_usage,
|
||||
other_is_path_connectable=other_is_path_connectable,
|
||||
)
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
@@ -189,6 +215,23 @@ def test_poll_rejects_when_other_is_not_layer2_wall():
|
||||
)
|
||||
|
||||
|
||||
def test_poll_accepts_fillet_corner_wall_partner():
|
||||
# Fillet-corner walls carry no LAYER2 usage by spec but the extend-to-
|
||||
# underside operator handles them just like a parametric LAYER2 wall —
|
||||
# the gizmo must surface for the slab + fillet-corner selection too.
|
||||
assert (
|
||||
_run_poll(
|
||||
prefs_on=True,
|
||||
active_is_in_selected=True,
|
||||
len_override=None,
|
||||
active_usage="LAYER3",
|
||||
other_usage=None,
|
||||
other_is_path_connectable=True,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# 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.
|
||||
|
||||
"""Pins the contract that ``DumbWallJoiner.merge`` re-hosts openings from
|
||||
the discarded wall to the survivor before the cascade delete tears down
|
||||
``element2.HasOpenings`` and any filling that references them.
|
||||
|
||||
``edit_object_placement`` preserves the opening's world position when the
|
||||
two walls have different placements — a ``PlacementRelTo`` swap alone
|
||||
would shift the opening as the relative offset changes."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _opening_rel(opening_id: int, placement_matrix: np.ndarray):
|
||||
"""Build a stub ``IfcRelVoidsElement`` carrying an opening with a known
|
||||
placement. ``RelatingBuildingElement`` is settable so the test can
|
||||
observe the re-host."""
|
||||
opening = Mock(name=f"opening_{opening_id}")
|
||||
opening.id.return_value = opening_id
|
||||
opening.ObjectPlacement = Mock(name=f"opening_placement_{opening_id}")
|
||||
rel = Mock(name=f"voids_rel_{opening_id}")
|
||||
rel.RelatedOpeningElement = opening
|
||||
rel.RelatingBuildingElement = None
|
||||
return rel, opening, placement_matrix
|
||||
|
||||
|
||||
def _merge_inputs(*, has_openings):
|
||||
"""Stage the minimum wall1 + wall2 + element1 + element2 surface that
|
||||
``DumbWallJoiner.merge`` reads. The reference lines and placements are
|
||||
rigged so the collinearity guard passes and execution reaches the
|
||||
opening-migration loop."""
|
||||
wall1 = Mock(name="wall1")
|
||||
wall2 = Mock(name="wall2")
|
||||
element1 = Mock(name="element1")
|
||||
element2 = Mock(name="element2")
|
||||
element1.ObjectPlacement = Mock(name="elem1_placement")
|
||||
element2.ObjectPlacement = Mock(name="elem2_placement")
|
||||
element1.ConnectedTo = []
|
||||
element1.ConnectedFrom = []
|
||||
element2.ConnectedTo = []
|
||||
element2.ConnectedFrom = []
|
||||
element2.HasOpenings = list(has_openings)
|
||||
return wall1, wall2, element1, element2
|
||||
|
||||
|
||||
def _run_merge(wall1, wall2, element1, element2, opening_matrices, captured_edit_calls):
|
||||
"""Invoke ``DumbWallJoiner().merge`` against the staged inputs with
|
||||
every heavy IFC / Blender side effect patched out. ``opening_matrices``
|
||||
maps an opening id to its captured world matrix; ``captured_edit_calls``
|
||||
is appended to whenever ``edit_object_placement`` fires."""
|
||||
from bonsai.bim.module.model.wall import DumbWallJoiner
|
||||
|
||||
def fake_get_local_placement(placement):
|
||||
for rel in element2.HasOpenings:
|
||||
if rel.RelatedOpeningElement.ObjectPlacement is placement:
|
||||
return opening_matrices[rel.RelatedOpeningElement.id()]
|
||||
return np.eye(4)
|
||||
|
||||
def fake_get_entity(obj):
|
||||
return {wall1: element1, wall2: element2}[obj]
|
||||
|
||||
def fake_edit_object_placement(ifc_file, *, product, matrix, is_si, should_transform_children):
|
||||
captured_edit_calls.append(
|
||||
{
|
||||
"product": product,
|
||||
"matrix": matrix,
|
||||
"is_si": is_si,
|
||||
"should_transform_children": should_transform_children,
|
||||
}
|
||||
)
|
||||
|
||||
p1 = np.array([0.0, 0.0])
|
||||
p2 = np.array([5.0, 0.0])
|
||||
p3 = np.array([5.0, 0.0])
|
||||
p4 = np.array([10.0, 0.0])
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False),
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity),
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line",
|
||||
side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4),
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement",
|
||||
side_effect=fake_get_local_placement,
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.edit_object_placement",
|
||||
side_effect=fake_edit_object_placement,
|
||||
),
|
||||
patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"),
|
||||
patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object") as delete_ifc_object,
|
||||
patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"),
|
||||
):
|
||||
DumbWallJoiner().merge(wall1, wall2)
|
||||
return delete_ifc_object
|
||||
|
||||
|
||||
def test_merge_rehosts_each_opening_to_survivor():
|
||||
"""Every void rel on the discarded wall is rebound to the survivor so
|
||||
the cascade delete doesn't take them down with element2."""
|
||||
matrix_a = np.eye(4)
|
||||
matrix_a[0, 3] = 1.0
|
||||
matrix_b = np.eye(4)
|
||||
matrix_b[0, 3] = 3.0
|
||||
rel_a, opening_a, _ = _opening_rel(opening_id=101, placement_matrix=matrix_a)
|
||||
rel_b, opening_b, _ = _opening_rel(opening_id=102, placement_matrix=matrix_b)
|
||||
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel_a, rel_b])
|
||||
|
||||
_run_merge(
|
||||
wall1,
|
||||
wall2,
|
||||
element1,
|
||||
element2,
|
||||
opening_matrices={101: matrix_a, 102: matrix_b},
|
||||
captured_edit_calls=[],
|
||||
)
|
||||
|
||||
assert rel_a.RelatingBuildingElement is element1
|
||||
assert rel_b.RelatingBuildingElement is element1
|
||||
|
||||
|
||||
def test_merge_preserves_opening_world_placement():
|
||||
"""``edit_object_placement`` re-applies the opening's pre-merge world
|
||||
matrix so the void doesn't drift when the two walls have different
|
||||
placements — the regression a ``PlacementRelTo`` swap alone would
|
||||
fail."""
|
||||
matrix = np.eye(4)
|
||||
matrix[:3, 3] = (2.5, 0.0, 0.0)
|
||||
rel, opening, _ = _opening_rel(opening_id=42, placement_matrix=matrix)
|
||||
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel])
|
||||
captured: list[dict] = []
|
||||
|
||||
_run_merge(
|
||||
wall1,
|
||||
wall2,
|
||||
element1,
|
||||
element2,
|
||||
opening_matrices={42: matrix},
|
||||
captured_edit_calls=captured,
|
||||
)
|
||||
|
||||
edit_calls_for_opening = [call for call in captured if call["product"] is opening]
|
||||
assert len(edit_calls_for_opening) == 1
|
||||
np.testing.assert_allclose(edit_calls_for_opening[0]["matrix"], matrix, atol=1e-9)
|
||||
assert edit_calls_for_opening[0]["should_transform_children"] is False
|
||||
|
||||
|
||||
def test_merge_rehosts_before_delete():
|
||||
"""Order matters: ``delete_ifc_object`` cascades through
|
||||
``element2.HasOpenings`` and would destroy the void if it ran before
|
||||
the re-host. Assert the survivor was rebound before delete fires."""
|
||||
matrix = np.eye(4)
|
||||
rel, opening, _ = _opening_rel(opening_id=7, placement_matrix=matrix)
|
||||
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[rel])
|
||||
|
||||
delete_ifc_object = _run_merge(
|
||||
wall1,
|
||||
wall2,
|
||||
element1,
|
||||
element2,
|
||||
opening_matrices={7: matrix},
|
||||
captured_edit_calls=[],
|
||||
)
|
||||
|
||||
assert rel.RelatingBuildingElement is element1
|
||||
delete_ifc_object.assert_called_once_with(wall2)
|
||||
|
||||
|
||||
def test_merge_skips_non_path_connection_rels():
|
||||
"""``ConnectedTo`` / ``ConnectedFrom`` carry both
|
||||
``IfcRelConnectsPathElements`` (wall-wall joins) AND
|
||||
``IfcRelConnectsElements`` (slab underside clips). Only the path rels
|
||||
expose ``RelatingConnectionType`` / ``RelatedConnectionType``;
|
||||
accessing those attributes on an element rel raises ``AttributeError``.
|
||||
The migration loop must filter on the rel class so a wall with a slab
|
||||
clip can still be merged."""
|
||||
from bonsai.bim.module.model.wall import DumbWallJoiner
|
||||
|
||||
wall1, wall2, element1, element2 = _merge_inputs(has_openings=[])
|
||||
|
||||
path_rel = Mock(name="path_rel")
|
||||
path_rel.is_a = lambda c: c == "IfcRelConnectsPathElements"
|
||||
path_rel.RelatingElement = Mock(name="rel_relating")
|
||||
path_rel.RelatedElement = Mock(name="rel_related")
|
||||
path_rel.RelatingConnectionType = "ATSTART"
|
||||
path_rel.RelatedConnectionType = "ATEND"
|
||||
|
||||
slab_rel = Mock(name="slab_rel")
|
||||
slab_rel.is_a = lambda c: c == "IfcRelConnectsElements"
|
||||
slab_rel.Description = "TOP"
|
||||
# ``RelatedConnectionType`` is what the merge loop reads from
|
||||
# ``ConnectedFrom``; the real ``IfcRelConnectsElements`` schema has
|
||||
# no such attribute, so wire the stub to raise like ifcopenshell does.
|
||||
type(slab_rel).RelatedConnectionType = property(
|
||||
lambda self: (_ for _ in ()).throw(AttributeError("RelatedConnectionType"))
|
||||
)
|
||||
type(slab_rel).RelatingConnectionType = property(
|
||||
lambda self: (_ for _ in ()).throw(AttributeError("RelatingConnectionType"))
|
||||
)
|
||||
element2.ConnectedFrom = [slab_rel, path_rel]
|
||||
|
||||
captured_disconnects = []
|
||||
captured_connects = []
|
||||
|
||||
def fake_disconnect_path(*args, **kwargs):
|
||||
captured_disconnects.append(kwargs)
|
||||
|
||||
def fake_connect_path(*args, **kwargs):
|
||||
captured_connects.append(kwargs)
|
||||
|
||||
p1 = np.array([0.0, 0.0])
|
||||
p2 = np.array([5.0, 0.0])
|
||||
p3 = np.array([5.0, 0.0])
|
||||
p4 = np.array([10.0, 0.0])
|
||||
|
||||
def fake_get_entity(obj):
|
||||
return {wall1: element1, wall2: element2}[obj]
|
||||
|
||||
with (
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.is_moved", return_value=False),
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get_entity", side_effect=fake_get_entity),
|
||||
patch("bonsai.bim.module.model.wall.tool.Ifc.get", return_value=MagicMock(name="ifc_file")),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.util.representation.get_reference_line",
|
||||
side_effect=lambda elem: (p1, p2) if elem is element1 else (p3, p4),
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.util.placement.get_local_placement",
|
||||
return_value=np.eye(4),
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.disconnect_path",
|
||||
side_effect=fake_disconnect_path,
|
||||
),
|
||||
patch(
|
||||
"bonsai.bim.module.model.wall.ifcopenshell.api.geometry.connect_path",
|
||||
side_effect=fake_connect_path,
|
||||
),
|
||||
patch("bonsai.bim.module.model.wall.tool.Model.recreate_wall"),
|
||||
patch("bonsai.bim.module.model.wall.tool.Geometry.delete_ifc_object"),
|
||||
patch("bonsai.bim.module.model.wall.DumbWallJoiner.set_axis"),
|
||||
):
|
||||
# The bug pre-fix: the slab rel's ``RelatedConnectionType`` access
|
||||
# raised AttributeError and crashed merge. With the filter, this
|
||||
# call must complete cleanly.
|
||||
DumbWallJoiner().merge(wall1, wall2)
|
||||
|
||||
assert len(captured_disconnects) == 1
|
||||
assert len(captured_connects) == 1
|
||||
assert captured_disconnects[0]["connection_type"] == "ATEND"
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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.
|
||||
|
||||
"""Pins the contract that the three dimension-mutating wall operators —
|
||||
``bim.change_extrusion_depth``, ``bim.change_extrusion_x_angle``,
|
||||
``bim.change_layer_length`` — re-prime ``BIMWallProperties`` from the
|
||||
post-mutation IFC at the end of ``_execute``.
|
||||
|
||||
Without the resync, ``props.height`` / ``props.length`` / ``props.x_angle``
|
||||
stay at their pre-mutation values; gizmo icons that position from
|
||||
``props.height`` then sit at the old elevation even though the wall mesh
|
||||
shows the new one."""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _execute_source(operator_cls):
|
||||
return inspect.getsource(operator_cls._execute)
|
||||
|
||||
|
||||
def test_change_extrusion_depth_resyncs_wall_props():
|
||||
"""Height mutation must re-prime ``BIMWallProperties.height`` so
|
||||
gizmo icons positioned from ``props.height`` track the post-mutation
|
||||
wall top in the same redraw."""
|
||||
from bonsai.bim.module.model.wall import ChangeExtrusionDepth
|
||||
|
||||
assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionDepth)
|
||||
|
||||
|
||||
def test_change_extrusion_x_angle_resyncs_wall_props():
|
||||
"""Slope mutation must re-prime ``BIMWallProperties.x_angle`` so
|
||||
slope-driven gizmo positions track the new angle."""
|
||||
from bonsai.bim.module.model.wall import ChangeExtrusionXAngle
|
||||
|
||||
assert "_resync_walls_after_mutation" in _execute_source(ChangeExtrusionXAngle)
|
||||
|
||||
|
||||
def test_change_layer_length_resyncs_wall_props():
|
||||
"""Length mutation must re-prime ``BIMWallProperties.length`` so
|
||||
horizontal gizmo X positions track the new axis extent."""
|
||||
from bonsai.bim.module.model.wall import ChangeLayerLength
|
||||
|
||||
assert "_resync_walls_after_mutation" in _execute_source(ChangeLayerLength)
|
||||
@@ -0,0 +1,213 @@
|
||||
# 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 wall-slab connection helpers on tool.Wall.
|
||||
|
||||
Pins the rel-shape contract (IfcRelConnectsElements with Description=="TOP")
|
||||
the underside-extension feature creates, and the icon placement contract the
|
||||
new wall-slab connection gizmo group reads."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _rel(klass: str = "IfcRelConnectsElements", description: str = "TOP", relating=None, related=None):
|
||||
rel = Mock()
|
||||
rel.is_a = lambda c: c == klass
|
||||
rel.Description = description
|
||||
rel.RelatingElement = relating
|
||||
rel.RelatedElement = related
|
||||
return rel
|
||||
|
||||
|
||||
def _wall_with_rels(*rels) -> Mock:
|
||||
wall = Mock()
|
||||
wall.ConnectedFrom = list(rels)
|
||||
return wall
|
||||
|
||||
|
||||
def _slab_with_rels(*rels) -> Mock:
|
||||
slab = Mock()
|
||||
slab.ConnectedTo = list(rels)
|
||||
return slab
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# iter_wall_slab_connections — yields (slab, rel) for TOP rels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_yields_top_rels():
|
||||
slab_a = Mock(name="slab_a")
|
||||
slab_b = Mock(name="slab_b")
|
||||
wall = _wall_with_rels(
|
||||
_rel(relating=slab_a),
|
||||
_rel(relating=slab_b),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_wall_slab_connections(wall))
|
||||
|
||||
assert result == [(slab_a, wall.ConnectedFrom[0]), (slab_b, wall.ConnectedFrom[1])]
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_skips_non_top_description():
|
||||
"""Only TOP-described rels count; BOTTOM / SIDE / arbitrary strings are
|
||||
skipped so other RelConnectsElements semantics aren't confused with the
|
||||
underside-extension contract."""
|
||||
slab = Mock()
|
||||
wall = _wall_with_rels(
|
||||
_rel(description="BOTTOM", relating=slab),
|
||||
_rel(description="TOP", relating=slab),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_wall_slab_connections(wall))
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0][0] is slab
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_skips_non_connectselements_rels():
|
||||
"""Path-connections to other walls show up on ConnectedFrom too — the
|
||||
helper must filter on rel class, not just presence."""
|
||||
slab = Mock()
|
||||
wall = _wall_with_rels(
|
||||
_rel(klass="IfcRelConnectsPathElements", relating=slab),
|
||||
_rel(klass="IfcRelConnectsElements", relating=slab),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_wall_slab_connections(wall))
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_handles_none_relating():
|
||||
"""A malformed rel with RelatingElement=None is skipped rather than
|
||||
raising — defensive against partially-loaded IFC files."""
|
||||
wall = _wall_with_rels(_rel(relating=None))
|
||||
|
||||
result = list(tool.Wall.iter_wall_slab_connections(wall))
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_iter_wall_slab_connections_empty_when_no_connectedfrom():
|
||||
wall = Mock()
|
||||
wall.ConnectedFrom = []
|
||||
|
||||
assert list(tool.Wall.iter_wall_slab_connections(wall)) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# iter_slab_wall_connections — mirror, walks slab.ConnectedTo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_iter_slab_wall_connections_yields_top_rels():
|
||||
wall_a = Mock()
|
||||
wall_b = Mock()
|
||||
slab = _slab_with_rels(
|
||||
_rel(related=wall_a),
|
||||
_rel(related=wall_b),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_slab_wall_connections(slab))
|
||||
|
||||
assert [w for w, _ in result] == [wall_a, wall_b]
|
||||
|
||||
|
||||
def test_iter_slab_wall_connections_skips_non_top():
|
||||
wall = Mock()
|
||||
slab = _slab_with_rels(
|
||||
_rel(description="BOTTOM", related=wall),
|
||||
_rel(description="TOP", related=wall),
|
||||
)
|
||||
|
||||
result = list(tool.Wall.iter_slab_wall_connections(slab))
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_wall_slab_rel — locate specific rel between wall + slab
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_find_wall_slab_rel_returns_match():
|
||||
slab_a = Mock(name="slab_a")
|
||||
slab_b = Mock(name="slab_b")
|
||||
rel_a = _rel(relating=slab_a)
|
||||
rel_b = _rel(relating=slab_b)
|
||||
wall = _wall_with_rels(rel_a, rel_b)
|
||||
|
||||
assert tool.Wall.find_wall_slab_rel(wall, slab_b) is rel_b
|
||||
|
||||
|
||||
def test_find_wall_slab_rel_returns_none_when_unconnected():
|
||||
slab_a = Mock(name="slab_a")
|
||||
other_slab = Mock(name="other_slab")
|
||||
wall = _wall_with_rels(_rel(relating=slab_a))
|
||||
|
||||
assert tool.Wall.find_wall_slab_rel(wall, other_slab) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wall_slab_connection_location_world — icon anchor point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
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():
|
||||
"""A wall without an IFC Axis representation has no reference line; the
|
||||
helper returns None so callers can skip rather than guess a location."""
|
||||
wall_obj = Mock()
|
||||
slab_obj = Mock()
|
||||
with patch.object(tool.Wall, "get_world_reference_line", return_value=None):
|
||||
assert tool.Wall.wall_slab_connection_location_world(wall_obj, slab_obj) is None
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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.
|
||||
|
||||
"""Pins two contracts in ``DumbWallJoiner.split``'s filled-opening branch:
|
||||
|
||||
1. Side classification reads the opening's axis-projected midpoint, not
|
||||
the filling's ``matrix_world.translation``. The filling origin is
|
||||
flip-fragile — flipping rotates the filler 180° + translates so the
|
||||
bbox stays visually in place, which would mis-classify a flipped door
|
||||
centred over the cut.
|
||||
2. When the void straddles the cut and the filling moves to element2,
|
||||
the void copy for element1 is taken from the ORIGINAL opening (whose
|
||||
``ObjectPlacement`` still references element1), not the rebound
|
||||
``new_opening`` (whose ``PlacementRelTo`` was swapped to element2)."""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _split_source():
|
||||
from bonsai.bim.module.model.wall import DumbWallJoiner
|
||||
|
||||
return inspect.getsource(DumbWallJoiner.split)
|
||||
|
||||
|
||||
def test_side_classification_uses_opening_midpoint_not_filling_origin():
|
||||
"""Side classification must read the opening's axis-projected
|
||||
midpoint, not the filling's world translation — the latter shifts
|
||||
under flipping and would mis-classify a flipped door centred over
|
||||
the cut."""
|
||||
source = _split_source()
|
||||
assert "opening_midpoint" in source
|
||||
assert "filling_obj.matrix_world.translation" not in source
|
||||
|
||||
|
||||
def test_void_copy_reads_from_original_opening_before_remove():
|
||||
"""When the filling moves to element2 and the void straddles the
|
||||
cut, element1's pure-void copy must come from the original opening
|
||||
BEFORE the cleanup that destroys it — the rebound ``new_opening``
|
||||
references element2's frame and would shift the void to element1's
|
||||
origin in element2's local coords."""
|
||||
source = _split_source()
|
||||
branch_start = source.index("if opening_midpoint > cut_percentage:")
|
||||
branch = source[branch_start:]
|
||||
add_idx = branch.index("_add_void_copy(element1, opening)")
|
||||
remove_idx = branch.index("feature.remove_feature(tool.Ifc.get(), feature=opening)")
|
||||
assert add_idx < remove_idx
|
||||
@@ -60,6 +60,13 @@ def collector():
|
||||
prophet.verify()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connection():
|
||||
prophet = Prophecy(bonsai.core.tool.Connection)
|
||||
yield prophet
|
||||
prophet.verify()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context():
|
||||
prophet = Prophecy(bonsai.core.tool.Context)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# 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.
|
||||
|
||||
"""Dispatch tests for ``core.connection.disconnect_rel``.
|
||||
|
||||
The dispatch is the single source of truth for per-kind cleanup shared by the
|
||||
explicit ``bim.disconnect_elements`` operator and the implicit cascade in
|
||||
``tool.Geometry.delete_ifc_object``. Each kind has one test that pins which
|
||||
helpers must be called; the AST forward-compat guard in
|
||||
``test_connection_forward_compat.py`` then asserts the dispatch table covers
|
||||
every kind ``Connection.find_rels`` can emit.
|
||||
|
||||
Uses ``unittest.mock`` directly (rather than the Prophecy fixtures) because
|
||||
the dispatch passes IFC rel entities with attribute access (``rel.RelatingElement``)
|
||||
that Prophecy's JSON call recorder can't serialize.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import bonsai.core.connection as subject
|
||||
|
||||
|
||||
def _rel(relating="slab", related="wall"):
|
||||
return SimpleNamespace(RelatingElement=relating, RelatedElement=related)
|
||||
|
||||
|
||||
def _ifc_with_objects(mapping):
|
||||
ifc = Mock()
|
||||
ifc.get_object.side_effect = lambda e: mapping.get(e)
|
||||
ifc.run = Mock()
|
||||
return ifc
|
||||
|
||||
|
||||
class TestDisconnectRelPath:
|
||||
def test_removes_connection_and_recreates_both_walls(self):
|
||||
ifc = _ifc_with_objects({"elem_a": "obj_a", "elem_b": "obj_b"})
|
||||
geometry = Mock()
|
||||
model = Mock()
|
||||
connection = Mock()
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel="rel", kind="path", elem="elem_a", partner="elem_b",
|
||||
)
|
||||
|
||||
remove.assert_called_once_with(geometry, connection="rel")
|
||||
model.recreate_wall.assert_any_call("elem_a", "obj_a")
|
||||
model.recreate_wall.assert_any_call("elem_b", "obj_b")
|
||||
assert model.recreate_wall.call_count == 2
|
||||
|
||||
def test_skip_elem_recreate_suppresses_elem_side(self):
|
||||
"""Cascade case: elem is being deleted — don't recreate it."""
|
||||
ifc = _ifc_with_objects({"elem": "elem_obj", "partner": "partner_obj"})
|
||||
geometry = Mock()
|
||||
model = Mock()
|
||||
connection = Mock()
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"):
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel="rel", kind="path", elem="elem", partner="partner",
|
||||
skip_elem_recreate=True,
|
||||
)
|
||||
|
||||
model.recreate_wall.assert_called_once_with("partner", "partner_obj")
|
||||
|
||||
def test_skip_partner_recreate_suppresses_partner_side(self):
|
||||
ifc = _ifc_with_objects({"elem": "elem_obj", "partner": "partner_obj"})
|
||||
geometry = Mock()
|
||||
model = Mock()
|
||||
connection = Mock()
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"):
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel="rel", kind="path", elem="elem", partner="partner",
|
||||
skip_partner_recreate=True,
|
||||
)
|
||||
|
||||
model.recreate_wall.assert_called_once_with("elem", "elem_obj")
|
||||
|
||||
def test_both_skips_means_only_remove_rel(self):
|
||||
ifc = _ifc_with_objects({})
|
||||
geometry = Mock()
|
||||
model = Mock()
|
||||
connection = Mock()
|
||||
|
||||
with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove:
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel="rel", kind="path", elem="elem", partner="partner",
|
||||
skip_elem_recreate=True,
|
||||
skip_partner_recreate=True,
|
||||
)
|
||||
|
||||
remove.assert_called_once()
|
||||
model.recreate_wall.assert_not_called()
|
||||
|
||||
|
||||
class TestDisconnectRelElementTop:
|
||||
def test_disconnects_then_regenerates_wall(self):
|
||||
"""Operator case (no skip flags): both sides survive, so the wall gets
|
||||
re-clipped against currently-connected slabs."""
|
||||
rel = _rel()
|
||||
ifc = _ifc_with_objects({"wall": "wall_obj"})
|
||||
geometry = Mock()
|
||||
model = Mock()
|
||||
connection = Mock()
|
||||
connection.orient_element_top.return_value = ("wall", "slab")
|
||||
|
||||
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
|
||||
subject.disconnect_rel(
|
||||
ifc, geometry, model, connection,
|
||||
rel=rel, kind="element-top", elem="elem", partner="partner",
|
||||
)
|
||||
|
||||
ifc.run.assert_called_once_with(
|
||||
"geometry.disconnect_element", relating_element="slab", related_element="wall"
|
||||
)
|
||||
regen.assert_called_once_with(ifc, geometry, model, ["wall_obj"])
|
||||
|
||||
def test_slab_delete_cascade_still_regenerates_wall(self):
|
||||
"""When slab is being deleted (elem=slab), wall survives and must
|
||||
re-clip against remaining connections — the cascade's main purpose."""
|
||||
rel = _rel()
|
||||
ifc = _ifc_with_objects({"wall": "wall_obj"})
|
||||
connection = Mock()
|
||||
connection.orient_element_top.return_value = ("wall", "slab")
|
||||
|
||||
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
|
||||
subject.disconnect_rel(
|
||||
ifc, Mock(), Mock(), connection,
|
||||
rel=rel, kind="element-top", elem="slab", partner="wall",
|
||||
skip_elem_recreate=True, # slab is being deleted
|
||||
)
|
||||
|
||||
regen.assert_called_once()
|
||||
|
||||
def test_wall_delete_cascade_skips_wall_regen(self):
|
||||
"""When the wall itself is being deleted, regenerating its body moments
|
||||
before remove_product wipes it is wasted work — skip."""
|
||||
rel = _rel()
|
||||
ifc = _ifc_with_objects({"wall": "wall_obj"})
|
||||
connection = Mock()
|
||||
connection.orient_element_top.return_value = ("wall", "slab")
|
||||
|
||||
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
|
||||
subject.disconnect_rel(
|
||||
ifc, Mock(), Mock(), connection,
|
||||
rel=rel, kind="element-top", elem="wall", partner="slab",
|
||||
skip_elem_recreate=True, # wall is being deleted
|
||||
)
|
||||
|
||||
regen.assert_not_called()
|
||||
ifc.run.assert_called_once() # rel still removed
|
||||
|
||||
def test_both_in_batch_skips_wall_regen(self):
|
||||
"""Batch delete of both endpoints, processing slab first: partner (wall)
|
||||
also queued for deletion → skip wall regen."""
|
||||
rel = _rel()
|
||||
ifc = _ifc_with_objects({"wall": "wall_obj"})
|
||||
connection = Mock()
|
||||
connection.orient_element_top.return_value = ("wall", "slab")
|
||||
|
||||
with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen:
|
||||
subject.disconnect_rel(
|
||||
ifc, Mock(), Mock(), connection,
|
||||
rel=rel, kind="element-top", elem="slab", partner="wall",
|
||||
skip_elem_recreate=True,
|
||||
skip_partner_recreate=True, # wall also in batch
|
||||
)
|
||||
|
||||
regen.assert_not_called()
|
||||
|
||||
|
||||
class TestDisconnectRelElement:
|
||||
def test_just_removes_the_rel(self):
|
||||
rel = _rel(relating="A", related="B")
|
||||
ifc = Mock()
|
||||
|
||||
subject.disconnect_rel(
|
||||
ifc, Mock(), Mock(), Mock(),
|
||||
rel=rel, kind="element", elem="elem_a", partner="elem_b",
|
||||
)
|
||||
|
||||
ifc.run.assert_called_once_with(
|
||||
"geometry.disconnect_element", relating_element="A", related_element="B"
|
||||
)
|
||||
|
||||
|
||||
class TestDisconnectRelUnknownKind:
|
||||
def test_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="Unknown rel kind"):
|
||||
subject.disconnect_rel(
|
||||
Mock(), Mock(), Mock(), Mock(),
|
||||
rel="rel", kind="bogus", elem="a", partner="b",
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
# 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.
|
||||
|
||||
"""Forward-compat AST contract: ``core.connection.disconnect_rel`` must have a
|
||||
branch for every rel ``kind`` emitted by ``tool.connection.Connection`` lookups.
|
||||
|
||||
Adding a new rel kind (e.g. ``"void"``, ``"fill"``, ``"interferes"``) to
|
||||
``find_rels`` / ``find_rels_for_element`` without extending ``disconnect_rel``
|
||||
would silently regress the disconnect operator and the cascade-on-delete: a new
|
||||
kind would reach the dispatch, hit the ``raise ValueError("Unknown rel kind")``
|
||||
fallback, and either crash the operator or leave the cascade half-done. This
|
||||
guard makes the symmetry mandatory at test time."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||
TOOL_CONNECTION = BONSAI_ROOT / "tool" / "connection.py"
|
||||
CORE_CONNECTION = BONSAI_ROOT / "core" / "connection.py"
|
||||
|
||||
|
||||
def _find_function(tree: ast.Module, name: str) -> ast.FunctionDef:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
return node
|
||||
raise AssertionError(f"Function {name!r} not found")
|
||||
|
||||
|
||||
def _find_method(tree: ast.Module, class_name: str, method_name: str) -> ast.FunctionDef:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
||||
for child in node.body:
|
||||
if isinstance(child, ast.FunctionDef) and child.name == method_name:
|
||||
return child
|
||||
raise AssertionError(f"Method {class_name}.{method_name} not found")
|
||||
|
||||
|
||||
def _kinds_emitted_by(method: ast.FunctionDef) -> set[str]:
|
||||
"""Extract every kind label this method emits.
|
||||
|
||||
Looks at exactly two narrow patterns to avoid false positives from
|
||||
docstrings or type-annotation strings:
|
||||
|
||||
- ``_record(rel, "<kind>", …)`` — positional string at index 1, the
|
||||
conventional emit shape in ``find_rels`` / ``find_rels_for_element``.
|
||||
- ``kind = "<a>" if … else "<b>"`` and chained variants — string
|
||||
literals on either branch of an ``ast.IfExp`` assigned to ``kind``.
|
||||
"""
|
||||
kinds: set[str] = set()
|
||||
for node in ast.walk(method):
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id == "_record" and len(node.args) >= 2:
|
||||
arg = node.args[1]
|
||||
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
||||
kinds.add(arg.value)
|
||||
elif isinstance(arg, ast.IfExp):
|
||||
for branch in (arg.body, arg.orelse):
|
||||
if isinstance(branch, ast.Constant) and isinstance(branch.value, str):
|
||||
kinds.add(branch.value)
|
||||
elif isinstance(node, ast.Assign):
|
||||
targets = [t for t in node.targets if isinstance(t, ast.Name) and t.id == "kind"]
|
||||
if not targets or not isinstance(node.value, ast.IfExp):
|
||||
continue
|
||||
for branch in (node.value.body, node.value.orelse):
|
||||
if isinstance(branch, ast.Constant) and isinstance(branch.value, str):
|
||||
kinds.add(branch.value)
|
||||
return kinds
|
||||
|
||||
|
||||
def _kind_branches_in_disconnect_rel(tree: ast.Module) -> set[str]:
|
||||
"""Return every kind matched by ``disconnect_rel``'s ``kind == "…"`` branches."""
|
||||
fn = _find_function(tree, "disconnect_rel")
|
||||
kinds: set[str] = set()
|
||||
for node in ast.walk(fn):
|
||||
if isinstance(node, ast.Compare) and len(node.ops) == 1 and isinstance(node.ops[0], ast.Eq):
|
||||
left = node.left
|
||||
right = node.comparators[0]
|
||||
if isinstance(left, ast.Name) and left.id == "kind":
|
||||
if isinstance(right, ast.Constant) and isinstance(right.value, str):
|
||||
kinds.add(right.value)
|
||||
return kinds
|
||||
|
||||
|
||||
def test_disconnect_rel_handles_every_kind_emitted_by_connection_lookups() -> None:
|
||||
tool_tree = ast.parse(TOOL_CONNECTION.read_text(encoding="utf-8"))
|
||||
core_tree = ast.parse(CORE_CONNECTION.read_text(encoding="utf-8"))
|
||||
|
||||
emitted = _kinds_emitted_by(_find_method(tool_tree, "Connection", "find_rels")) | _kinds_emitted_by(
|
||||
_find_method(tool_tree, "Connection", "find_rels_for_element")
|
||||
)
|
||||
handled = _kind_branches_in_disconnect_rel(core_tree)
|
||||
|
||||
assert emitted, "Sanity check: no kinds extracted — emit pattern may have changed"
|
||||
|
||||
missing = emitted - handled
|
||||
assert not missing, (
|
||||
f"core.connection.disconnect_rel is missing branches for kinds {missing}. "
|
||||
f"Every kind returned by Connection.find_rels / find_rels_for_element "
|
||||
f"must have a matching if/elif branch in the dispatch."
|
||||
)
|
||||
@@ -135,6 +135,37 @@ class TestGetRepresentationData(NewFile):
|
||||
assert subject.get_representation_data(representation) == data
|
||||
|
||||
|
||||
class TestGetActiveRepresentation(NewFile):
|
||||
def test_returns_representation_for_live_id(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
representation = ifc.createIfcShapeRepresentation()
|
||||
mesh = bpy.data.meshes.new("Mesh")
|
||||
obj = bpy.data.objects.new("Object", mesh)
|
||||
tool.Geometry.get_mesh_props(mesh).ifc_definition_id = representation.id()
|
||||
assert subject.get_active_representation(obj) == representation
|
||||
|
||||
def test_returns_none_when_mesh_has_no_id(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
obj = bpy.data.objects.new("Object", bpy.data.meshes.new("Mesh"))
|
||||
assert subject.get_active_representation(obj) is None
|
||||
|
||||
def test_returns_none_when_id_is_stale(self):
|
||||
"""A representation rebuild can free the old entity while obj.data
|
||||
still tracks its id. Returning ``None`` keeps every UI redraw alive
|
||||
instead of spamming ``RuntimeError`` from the by_id lookup."""
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
representation = ifc.createIfcShapeRepresentation()
|
||||
mesh = bpy.data.meshes.new("Mesh")
|
||||
obj = bpy.data.objects.new("Object", mesh)
|
||||
stale_id = representation.id()
|
||||
tool.Geometry.get_mesh_props(mesh).ifc_definition_id = stale_id
|
||||
ifc.remove(representation)
|
||||
assert subject.get_active_representation(obj) is None
|
||||
|
||||
|
||||
class TestGetRepresentationId(NewFile):
|
||||
def test_run(self):
|
||||
ifc = ifcopenshell.file()
|
||||
|
||||
Reference in New Issue
Block a user