mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 09:48:32 +00:00
Generalise opening gizmos + DRY toolbar plumbing
Add openings — GizmoWallAddOpening only fired when a wall was active + co-selected with a non-host; slabs and roofs got no in-viewport handle. GizmoHostAddOpening covers all three host types via is_supported_host, dispatching walls to the axis-projection anchor and slabs/roofs to a world-Z anchor lifted just above the host's top face (predictable height regardless of the void's vertical position). Show openings on hosts with their own parametric-edit toolbar — GizmoRoofEdition gains an idle-row toggle_openings_gizmo parallel to the wall's, parked at the cancel-slot X next to the pen. Visible only when the host carries HasOpenings and the edit triad is idle. Roof overrides get_element_height to return the mesh's world-AABB top in object-local Z, so the WHOLE pen-row anchors visibly above sloped or stepped roof bodies. The wall's idle-row toggle now also hides when HasOpenings is empty. Show openings on hosts WITHOUT a parametric-edit toolbar — GizmoHostToggleOpenings scoped strictly to the fallback case: a single host selected, HasOpenings non-empty, NOT a path-connectable wall, NOT a parametric roof. Covers slabs today plus any foreign-authored IfcRoof without BBIM_Roof. Anchored at object origin XY + world-AABB top Z. When slab parametric-edit eventually lands, the slab predicate joins the exclusion list and this gizmo's poll narrows automatically. Operator move — ToggleWallOpenings was already host-agnostic; renamed to ToggleHostOpenings in opening.py (bl_idname bim.toggle_host_openings). Three callers (the wall idle-row binding, GizmoWallFilletToggleOpenings, and workspace.py's hotkey_A_O for Alt+O) now route through the renamed operator. The Alt+O binding is surfaced in the operator's bl_description so it appears in F3 search and hover tooltips. DRY refactors — * GizmoWallAddOpening deleted (subsumed by GizmoHostAddOpening) * tool.Blender.get_object_world_bounding_box added as the world-AABB sibling of the existing local helper; 3 inline call sites in tool/misc.py (set_object_origin_to_bottom, scale_object_to_height) and gizmos.py adopt it (2 other sites in drawing/operator.py and project/operator.py inherently need raw transformed corners for per-corner plane / NDC tests — not AABB candidates) * BaseParametricGizmoGroup gains setup_pen_row_toggle_openings_icon + update_pen_row_toggle_openings_icon; wall + roof + any future host gizmo wire up the idle-row toggle with two one-line calls * _resolve_active_host shared poll prologue between the two host gizmos (gate + selection count + active-in-selected + entity lookup + supported-host check) * HasOpenings non-empty checks at 3 sites route through tool.Geometry.has_openings * hotkey_A_O body collapsed to bpy.ops.bim.toggle_host_openings() The forward-compat AST guard pinning "must accept fillet-corner walls" retargets from GizmoWallAddOpening.poll to is_supported_host. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -1501,24 +1501,11 @@ class SnapManager:
|
||||
nearby_objects = []
|
||||
|
||||
for obj in mesh_objects:
|
||||
bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
|
||||
if not bbox_corners:
|
||||
if not obj.bound_box:
|
||||
continue
|
||||
|
||||
bbox_min = Vector(
|
||||
(
|
||||
min(c.x for c in bbox_corners),
|
||||
min(c.y for c in bbox_corners),
|
||||
min(c.z for c in bbox_corners),
|
||||
)
|
||||
)
|
||||
bbox_max = Vector(
|
||||
(
|
||||
max(c.x for c in bbox_corners),
|
||||
max(c.y for c in bbox_corners),
|
||||
max(c.z for c in bbox_corners),
|
||||
)
|
||||
)
|
||||
bbox = tool.Blender.get_object_world_bounding_box(obj)
|
||||
bbox_min = bbox["min_point"]
|
||||
bbox_max = bbox["max_point"]
|
||||
|
||||
closest = Vector(
|
||||
(
|
||||
@@ -5755,6 +5742,45 @@ class BaseParametricGizmoGroup:
|
||||
def get_element_height(self, props) -> float:
|
||||
return getattr(props, "overall_height", getattr(props, "height", 1.0))
|
||||
|
||||
def setup_pen_row_toggle_openings_icon(self) -> None:
|
||||
"""Create ``self.toggle_openings_gizmo`` bound to
|
||||
``bim.toggle_host_openings``. Subclasses call this from
|
||||
``setup_element_specific_gizmos`` to opt their host into the shared
|
||||
idle-row toggle; pair with
|
||||
``update_pen_row_toggle_openings_icon`` in
|
||||
``_refresh_element_specific``."""
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.toggle_openings_gizmo = self._setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening",
|
||||
default_color,
|
||||
"bim.toggle_host_openings",
|
||||
highlight_color,
|
||||
)
|
||||
|
||||
def update_pen_row_toggle_openings_icon(self, context: bpy.types.Context, mw: "Matrix", props) -> None:
|
||||
"""Position ``self.toggle_openings_gizmo`` at the cancel-slot X next
|
||||
to the pen in idle state; hide during edit (the validate/cancel row
|
||||
owns that X) and when the active host carries no openings.
|
||||
|
||||
Subclasses opt in by calling
|
||||
``setup_pen_row_toggle_openings_icon`` in
|
||||
``setup_element_specific_gizmos`` and this method from
|
||||
``_refresh_element_specific``. No-op for groups that never
|
||||
created the icon."""
|
||||
if not hasattr(self, "toggle_openings_gizmo"):
|
||||
return
|
||||
obj = context.active_object
|
||||
element = tool.Ifc.get_entity(obj) if obj is not None else None
|
||||
has_openings = element is not None and tool.Geometry.has_openings(element)
|
||||
if props.is_editing or not has_openings:
|
||||
self.toggle_openings_gizmo.hide = True
|
||||
return
|
||||
self.toggle_openings_gizmo.hide = self.is_gizmo_hidden_by_modal(self.toggle_openings_gizmo)
|
||||
icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET
|
||||
icon_y = self.get_icon_y_offset(context, mw)
|
||||
world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z))
|
||||
self.toggle_openings_gizmo.matrix_basis = billboarded_at(world_pos, self._frame_billboard_rot)
|
||||
|
||||
def is_gizmo_hidden_by_modal(self, gizmo: bpy.types.Gizmo) -> bool:
|
||||
"""Check if a gizmo should be hidden because a modal operator is active.
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from . import (
|
||||
external,
|
||||
grid,
|
||||
handler,
|
||||
host_add_opening_gizmo,
|
||||
mep,
|
||||
opening,
|
||||
product,
|
||||
@@ -100,7 +101,8 @@ classes = (
|
||||
wall.ExtendWallToCursor,
|
||||
wall.FinishEditingWall,
|
||||
wall.FlipWall,
|
||||
wall.GizmoWallAddOpening,
|
||||
host_add_opening_gizmo.GizmoHostAddOpening,
|
||||
host_add_opening_gizmo.GizmoHostToggleOpenings,
|
||||
wall.GizmoWallEdition,
|
||||
wall.GizmoWallExtendVertically,
|
||||
wall.GizmoWallFilletPreview,
|
||||
@@ -116,7 +118,6 @@ classes = (
|
||||
wall.RotateWall90,
|
||||
wall.SplitWall,
|
||||
wall.SplitWallAtCursor,
|
||||
wall.ToggleWallOpenings,
|
||||
wall.UnjoinWallPathConnection,
|
||||
wall.UnjoinWalls,
|
||||
wall.EnableWallFilletPreview,
|
||||
@@ -135,6 +136,7 @@ classes = (
|
||||
opening.RemoveBoolean,
|
||||
opening.SelectBoolean,
|
||||
opening.ShowOpenings,
|
||||
opening.ToggleHostOpenings,
|
||||
opening.UpdateOpeningsFocus,
|
||||
profile.ChangeCardinalPoint,
|
||||
profile.ChangeProfileDepth,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# 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 single-click "Add Opening" gizmo for hosts (walls, slabs, roofs).
|
||||
|
||||
One GizmoGroup serves every IFC host type that exposes ``HasOpenings``:
|
||||
parametric LAYER2 walls, any ``IfcSlab``, and any ``IfcRoof``. The poll
|
||||
guards host-host pairings so this gizmo never overlaps with the existing
|
||||
wall-join / extend-vertically gizmos. The positioner dispatches on element
|
||||
type — walls use axis-projection + camera-facing-Y math (which requires the
|
||||
parametric layer-set); slabs and roofs use a world-Z face bias driven by
|
||||
the void object's elevation against the host's bounding box."""
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.model.wall import (
|
||||
_get_wall_geom_cached,
|
||||
_wall_camera_facing_icon_y,
|
||||
_wall_gizmo_poll_gate,
|
||||
_WallGeomCachedBillboardingMixin,
|
||||
)
|
||||
|
||||
|
||||
def is_supported_host(element) -> bool:
|
||||
"""Total predicate (None → False). Walls accept either a parametric
|
||||
LAYER2 wall OR a fillet-corner wall (both expose a usable axis +
|
||||
layer-set for the anchor math); slabs and roofs only need the bound
|
||||
box so any IfcSlab / IfcRoof qualifies regardless of parametric
|
||||
modifier state."""
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
|
||||
|
||||
|
||||
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
|
||||
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
|
||||
selected + IFC entity lookup + supported-host predicate. Returns the
|
||||
active element on success, ``None`` on any failure — callers chain their
|
||||
feature-specific checks past the early-return."""
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
return None
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
if len(selected) != n_selected:
|
||||
return None
|
||||
active = context.active_object
|
||||
if active is None or active not in selected:
|
||||
return None
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if not element or not is_supported_host(element):
|
||||
return None
|
||||
return element
|
||||
|
||||
|
||||
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Activates when a host element (wall / slab / roof) is the active object
|
||||
and exactly one other selected object is *not* itself a host.
|
||||
|
||||
Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
|
||||
projected location on the host. A click dispatches ``bim.add_opening``,
|
||||
which handles any element exposing the ``HasOpenings`` inverse.
|
||||
|
||||
Per-frame positioning keeps the icon facing the camera as the viewport
|
||||
orbits."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_host_add_opening"
|
||||
bl_label = "Host Add Opening Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
element = _resolve_active_host(context, n_selected=2)
|
||||
if element is None:
|
||||
return False
|
||||
# The operator itself filters on HasOpenings, but checking here keeps
|
||||
# the icon from appearing on host classes that can't accept openings
|
||||
# in the active IFC schema.
|
||||
if not hasattr(element, "HasOpenings"):
|
||||
return False
|
||||
active = context.active_object
|
||||
other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
|
||||
# Host + host pairings are claimed by host-specific gizmos (wall-join,
|
||||
# extend-vertical, …) — suppress here so the add-opening icon never
|
||||
# stacks on top of them.
|
||||
if is_supported_host(tool.Ifc.get_entity(other)):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.add_opening_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening"
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
host_obj = context.active_object
|
||||
if not host_obj:
|
||||
return
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
other = next((o for o in selected if o is not host_obj), None)
|
||||
if not other:
|
||||
return
|
||||
element = tool.Ifc.get_entity(host_obj)
|
||||
if not element:
|
||||
return
|
||||
|
||||
if tool.Parametric.is_path_connectable_wall(element):
|
||||
world_pos = wall_anchor(context, self, host_obj, other)
|
||||
else:
|
||||
world_pos = layer3_anchor(host_obj, other)
|
||||
if world_pos is None:
|
||||
return
|
||||
self.add_opening_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
|
||||
|
||||
|
||||
def wall_anchor(
|
||||
context: bpy.types.Context, group: bpy.types.GizmoGroup, wall_obj: bpy.types.Object, other: bpy.types.Object
|
||||
) -> Vector | None:
|
||||
"""World-space anchor for the add-opening icon on a wall host: void origin
|
||||
projected onto the wall reference-line X (clamped to wall extents), lifted to
|
||||
the camera-facing wall-local Y."""
|
||||
geom = _get_wall_geom_cached(group, wall_obj)
|
||||
if not geom:
|
||||
return None
|
||||
mw = wall_obj.matrix_world
|
||||
wall_local = mw.inverted() @ other.matrix_world.translation
|
||||
local_x = max(geom["anchor_x"], min(wall_local.x, geom["anchor_x"] + geom["length"]))
|
||||
icon_y = _wall_camera_facing_icon_y(context, mw, geom)
|
||||
base_world = mw @ Vector((local_x, icon_y, 0.0))
|
||||
top_world = mw @ Vector((local_x, icon_y, geom["height"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET))
|
||||
return gizmo.BaseParametricGizmoGroup.pick_visible_anchor(context, base_world, top_world)
|
||||
|
||||
|
||||
def layer3_anchor(host_obj: bpy.types.Object, other: bpy.types.Object) -> Vector:
|
||||
"""World-space anchor for the add-opening icon on a LAYER3 host (slab / roof):
|
||||
void's world XY, lifted just above the host's top face. Predictable height
|
||||
regardless of where the void sits vertically — clicking the icon places the
|
||||
opening at the void's XY, and the operator handles the actual cut depth."""
|
||||
bbox = tool.Blender.get_object_world_bounding_box(host_obj)
|
||||
anchor_xy = other.matrix_world.translation.xy
|
||||
top_z = bbox["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
|
||||
return Vector((anchor_xy.x, anchor_xy.y, top_z))
|
||||
|
||||
|
||||
def host_toggle_anchor(host_obj: bpy.types.Object) -> Vector:
|
||||
"""Object origin XY, lifted just above the topmost mesh vertex. Tracks
|
||||
the parametric origin (useful reference even when the mesh extends
|
||||
asymmetrically) and the visible top face (stays clear of sloped or
|
||||
stepped bodies)."""
|
||||
origin = host_obj.matrix_world.translation
|
||||
top_z = tool.Blender.get_object_world_bounding_box(host_obj)["max_z"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
|
||||
return Vector((origin.x, origin.y, top_z))
|
||||
|
||||
|
||||
class GizmoHostToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Fallback toggle-openings icon for hosts that lack their own
|
||||
parametric-edit toolbar — slabs today, plus any foreign-authored
|
||||
IfcRoof that carries no BBIM_Roof pset (so ``GizmoRoofEdition`` doesn't
|
||||
poll for it). Walls and parametric roofs already render an idle-row
|
||||
toggle next to the pen and are excluded from this poll.
|
||||
|
||||
When slab parametric-edit lands the slab branch will pen-row-handle
|
||||
its own toggle; updating the exclusion predicate here is the only
|
||||
migration step needed."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_host_toggle_openings"
|
||||
bl_label = "Host Toggle Openings Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
element = _resolve_active_host(context, n_selected=1)
|
||||
if element is None:
|
||||
return False
|
||||
if not tool.Geometry.has_openings(element):
|
||||
return False
|
||||
# Skip when a per-feature parametric-edit gizmo already surfaces
|
||||
# an idle-row toggle for this element (wall: GizmoWallEdition;
|
||||
# parametric roof: GizmoRoofEdition).
|
||||
if tool.Parametric.is_path_connectable_wall(element):
|
||||
return False
|
||||
if tool.Parametric.is_roof(element):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.toggle_openings_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.toggle_host_openings"
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
host_obj = context.active_object
|
||||
if not host_obj:
|
||||
return
|
||||
self.toggle_openings_icon.matrix_basis = gizmo.billboarded_at(
|
||||
host_toggle_anchor(host_obj), gizmo.get_billboard_rotation(context)
|
||||
)
|
||||
@@ -737,6 +737,29 @@ class AddBoolean(Operator, tool.Ifc.Operator):
|
||||
tool.Root.reload_item_decorator()
|
||||
|
||||
|
||||
class ToggleHostOpenings(Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.toggle_host_openings"
|
||||
bl_label = "Toggle Openings"
|
||||
bl_description = "Show or hide opening fills (doors and windows) in the viewport\n\nHotkey: Alt+O"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Model.has_selected_ifc_objects():
|
||||
cls.poll_message_set("No IFC objects selected.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
# Opening visibility is independent of host geometry — don't commit any
|
||||
# active parametric edit; the user can keep editing the host.
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ShowOpenings(Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.show_openings"
|
||||
bl_label = "Show Openings"
|
||||
|
||||
@@ -755,6 +755,25 @@ class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
self.set_dimension_gizmo_position("angle", mw, origin, (0, 0, 1))
|
||||
self.set_dimension_gizmo_position("roof_thickness", mw, origin, (0, 0, -1))
|
||||
|
||||
def get_element_height(self, props) -> float: # noqa: ARG002
|
||||
"""Object-local Z of the mesh's topmost vertex, so the pen / validate /
|
||||
cancel / cycle row anchors visibly above sloped or stepped roof
|
||||
bodies rather than at the parametric ``props.height`` which may not
|
||||
match the rendered apex on ANGLE-generation roofs."""
|
||||
obj = bpy.context.active_object
|
||||
if obj is None or not getattr(obj, "bound_box", None):
|
||||
return 1.0
|
||||
return max(c[2] for c in obj.bound_box)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""One idle-row icon outside the slot system: the ``toggle_openings``
|
||||
button. Mirrors the wall idle row — pen + opening sit side by side
|
||||
when the roof is selected and already carries at least one opening."""
|
||||
self.setup_pen_row_toggle_openings_icon()
|
||||
|
||||
def _refresh_element_specific(self, context: bpy.types.Context, mw, props) -> None:
|
||||
self.update_pen_row_toggle_openings_icon(context, mw, props)
|
||||
|
||||
|
||||
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_roof_path"
|
||||
|
||||
@@ -2072,12 +2072,7 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
"bim.extend_wall_height_to_cursor",
|
||||
highlight_color,
|
||||
)
|
||||
self.toggle_openings_gizmo = self._setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening",
|
||||
default_color,
|
||||
"bim.toggle_wall_openings",
|
||||
highlight_color,
|
||||
)
|
||||
self.setup_pen_row_toggle_openings_icon()
|
||||
|
||||
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None:
|
||||
"""Position cursor-anchored gizmos and the wall-specific icon-row extras."""
|
||||
@@ -2178,14 +2173,7 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
|
||||
Toggle-openings is NOT in the slot system — it surfaces in IDLE
|
||||
state (alongside the pen, not in the edit row), so it's positioned
|
||||
manually here."""
|
||||
if not hasattr(self, "toggle_openings_gizmo"):
|
||||
return
|
||||
icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET
|
||||
icon_y = self.get_icon_y_offset(context, mw)
|
||||
billboard_rot = self._frame_billboard_rot
|
||||
|
||||
# --- Baseline variant visibility ---
|
||||
via the base's shared helper here."""
|
||||
active_variant = self._BASELINE_TO_VARIANT.get(props.desired_offset_baseline)
|
||||
for variant in ("exterior", "center", "interior"):
|
||||
gz = getattr(self, f"baseline_{variant}_gizmo", None)
|
||||
@@ -2195,16 +2183,7 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
gz.hide = self.is_gizmo_hidden_by_modal(gz)
|
||||
else:
|
||||
gz.hide = True
|
||||
|
||||
# --- Idle-row toggle-openings (outside the slot system) ---
|
||||
# Sits at the slot the cancel icon occupies during editing — that way the
|
||||
# pen + openings pair is compact and visually grouped.
|
||||
if not props.is_editing:
|
||||
self.toggle_openings_gizmo.hide = self.is_gizmo_hidden_by_modal(self.toggle_openings_gizmo)
|
||||
world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z))
|
||||
self.toggle_openings_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
|
||||
else:
|
||||
self.toggle_openings_gizmo.hide = True
|
||||
self.update_pen_row_toggle_openings_icon(context, mw, props)
|
||||
|
||||
|
||||
def _apply_wall_extend_flips(
|
||||
@@ -2359,29 +2338,6 @@ class RotateWall90(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ToggleWallOpenings(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.toggle_wall_openings"
|
||||
bl_label = "Toggle Openings"
|
||||
bl_description = "Show or hide opening fills (doors and windows) in the viewport"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Model.has_selected_ifc_objects():
|
||||
cls.poll_message_set("No IFC objects selected.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _execute(self, context: bpy.types.Context) -> set[str]:
|
||||
# Opening visibility is independent of wall geometry — don't commit the
|
||||
# active wall edit; the user can keep editing the wall.
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def _wall_axis_world_segment_from_geom(obj: bpy.types.Object, geom: dict) -> tuple[Vector, Vector]:
|
||||
"""Compose the world-space axis segment from an already-read ``geom`` dict.
|
||||
Used by the billboarding gizmo groups so a single cached IFC read drives both
|
||||
@@ -3305,71 +3261,6 @@ def _wall_fillet_preview_walls(context: bpy.types.Context):
|
||||
return wall_a_obj, wall_b_obj
|
||||
|
||||
|
||||
class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Activates when a wall (active) and one non-wall blender object are co-selected.
|
||||
|
||||
Renders a single icon above the wall at the wall-local X corresponding to the other
|
||||
object's projected origin. Clicking dispatches `bim.add_opening`, which lets the
|
||||
existing FilledOpeningGenerator decide how the opening is applied.
|
||||
|
||||
Per-frame positioning via `BillboardingGizmoGroupMixin` ensures the icon
|
||||
keeps facing the camera as the viewport is orbited."""
|
||||
|
||||
bl_idname = "OBJECT_GGT_bim_wall_add_opening"
|
||||
bl_label = "Wall Add Opening Gizmo"
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "WINDOW"
|
||||
bl_options = {"3D", "PERSISTENT"}
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context: bpy.types.Context) -> bool:
|
||||
if not _wall_gizmo_poll_gate(context):
|
||||
return False
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
if len(selected) != 2:
|
||||
return False
|
||||
active = context.active_object
|
||||
if active is None or active not in selected:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if not element or not tool.Parametric.is_path_connectable_wall(element):
|
||||
return False
|
||||
other = next(o for o in selected if o is not active)
|
||||
# If the other object is also a wall, the wall-join gizmo handles it instead.
|
||||
other_element = tool.Ifc.get_entity(other)
|
||||
if other_element and tool.Parametric.is_path_connectable_wall(other_element):
|
||||
return False
|
||||
return True
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.add_opening_icon = self.setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening"
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
wall_obj = context.active_object
|
||||
if not wall_obj:
|
||||
return
|
||||
selected = tool.Blender.get_selected_objects()
|
||||
other = next((o for o in selected if o is not wall_obj), None)
|
||||
if not other:
|
||||
return
|
||||
geom = _get_wall_geom_cached(self, wall_obj)
|
||||
if not geom:
|
||||
return
|
||||
mw = wall_obj.matrix_world
|
||||
wall_local = mw.inverted() @ other.matrix_world.translation
|
||||
local_x = max(geom["anchor_x"], min(wall_local.x, geom["anchor_x"] + geom["length"]))
|
||||
# Place the icon on the camera-facing side of the wall, like the pen icon
|
||||
# does for parametric edits — orbit the camera past the wall and the icon
|
||||
# jumps to the visible face instead of being stranded behind it.
|
||||
icon_y = _wall_camera_facing_icon_y(context, mw, geom)
|
||||
icon_z = geom["height"] + gizmo.BaseParametricGizmoGroup.ICON_Z_OFFSET
|
||||
world_pos = mw @ Vector((local_x, icon_y, icon_z))
|
||||
self.add_opening_icon.matrix_basis = gizmo.billboarded_at(world_pos, gizmo.get_billboard_rotation(context))
|
||||
|
||||
|
||||
class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
|
||||
"""Activates when a LAYER3 element (typically a slab) is active and a LAYER2
|
||||
wall is co-selected. Mirrors the N-panel ``Extend To Underside`` button (which
|
||||
@@ -4123,7 +4014,7 @@ class GizmoWallFilletToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboa
|
||||
"VIEW3D_GT_add_opening",
|
||||
default_color,
|
||||
highlight_color,
|
||||
"bim.toggle_wall_openings",
|
||||
"bim.toggle_host_openings",
|
||||
)
|
||||
|
||||
def position_gizmos(self, context: bpy.types.Context) -> None:
|
||||
|
||||
@@ -1444,10 +1444,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.ops.bim.enable_editing_extrusion_axis()
|
||||
|
||||
def hotkey_A_O(self):
|
||||
if tool.Model.get_model_props().openings:
|
||||
bpy.ops.bim.edit_openings(apply_all=True)
|
||||
else:
|
||||
bpy.ops.bim.show_openings()
|
||||
bpy.ops.bim.toggle_host_openings()
|
||||
|
||||
def hotkey_C_E(self):
|
||||
if not bpy.context.selected_objects:
|
||||
|
||||
@@ -893,6 +893,34 @@ class Blender(bonsai.core.tool.Blender):
|
||||
}
|
||||
return bbox_dict
|
||||
|
||||
@classmethod
|
||||
def get_object_world_bounding_box(cls, obj: bpy.types.Object) -> dict[str, Union[float, Vector]]:
|
||||
"""Same shape as ``get_object_bounding_box`` but with ``matrix_world``
|
||||
applied — extents are computed across the 8 transformed corners, so
|
||||
a rotated or scaled object reports its actual world-axis AABB rather
|
||||
than the misleading transform of the local-space corners.
|
||||
|
||||
``bound_box[0]`` / ``bound_box[6]`` are the local min/max corners but
|
||||
do NOT correspond to the world AABB extremes once the object is
|
||||
rotated, so min/max must be taken per-axis across all 8 corners."""
|
||||
corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
xs = [c.x for c in corners]
|
||||
ys = [c.y for c in corners]
|
||||
zs = [c.z for c in corners]
|
||||
min_point = Vector((min(xs), min(ys), min(zs)))
|
||||
max_point = Vector((max(xs), max(ys), max(zs)))
|
||||
return {
|
||||
"min_x": min_point.x,
|
||||
"max_x": max_point.x,
|
||||
"min_y": min_point.y,
|
||||
"max_y": max_point.y,
|
||||
"min_z": min_point.z,
|
||||
"max_z": max_point.z,
|
||||
"min_point": min_point,
|
||||
"max_point": max_point,
|
||||
"center": (min_point + max_point) / 2,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def select_and_activate_single_object(cls, context: bpy.types.Context, active_object: bpy.types.Object) -> None:
|
||||
for obj in context.selected_objects:
|
||||
|
||||
@@ -227,10 +227,8 @@ class Misc(bonsai.core.tool.Misc):
|
||||
|
||||
@classmethod
|
||||
def set_object_origin_to_bottom(cls, obj: bpy.types.Object) -> None:
|
||||
absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
min_z = min([c[2] for c in absolute_bound_box])
|
||||
new_origin = obj.matrix_world.translation.copy()
|
||||
new_origin[2] = min_z
|
||||
new_origin[2] = tool.Blender.get_object_world_bounding_box(obj)["min_z"]
|
||||
assert isinstance(obj.data, bpy.types.Mesh)
|
||||
obj.data.transform(
|
||||
Matrix.Translation(
|
||||
@@ -249,11 +247,8 @@ class Misc(bonsai.core.tool.Misc):
|
||||
|
||||
@classmethod
|
||||
def scale_object_to_height(cls, obj: bpy.types.Object, height: float) -> None:
|
||||
absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
|
||||
max_z = max([c[2] for c in absolute_bound_box])
|
||||
min_z = min([c[2] for c in absolute_bound_box])
|
||||
current_absolute_height = max_z - min_z
|
||||
scale_factor = height / current_absolute_height
|
||||
bbox = tool.Blender.get_object_world_bounding_box(obj)
|
||||
scale_factor = height / (bbox["max_z"] - bbox["min_z"])
|
||||
obj.matrix_world @= Matrix.Scale(
|
||||
scale_factor, 4, obj.matrix_world.inverted().to_quaternion() @ Vector((0, 0, 1))
|
||||
)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# 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 fixtures and factories for ``test/bim/module/model/`` gizmo and
|
||||
decorator tests.
|
||||
|
||||
The boundary between Blender / IFC / Bonsai's ``tool.*`` layer is patched
|
||||
identically across many model-test files (viewport-state, selection, IFC
|
||||
entity lookup, modifier predicates, view-camera state). The ``patched_tool``
|
||||
fixture below centralises that patch stack so each test names only the
|
||||
boundary methods it cares about; everything else is left to production.
|
||||
|
||||
Factory helpers (``make_obj``, ``make_element``, ``make_context``,
|
||||
``make_ifc_file``) replace near-identical local helpers that previously
|
||||
lived in each file.
|
||||
|
||||
When to use these fixtures in a new test file:
|
||||
|
||||
- Adding a gizmo / decorator test that patches ``tool.Blender`` or
|
||||
``tool.Ifc`` boundary methods? Request the ``patched_tool`` fixture
|
||||
as a test parameter and call it as a context-manager factory.
|
||||
- Need a stub ``bpy.types.Object`` / ``ifcopenshell.entity_instance`` /
|
||||
``poll()`` context / ``ifcopenshell.file``? Import the matching factory
|
||||
from this module rather than re-rolling locally.
|
||||
- Need to reset module-level state (e.g. a decorator cache token) between
|
||||
tests? Define an ``@pytest.fixture(autouse=True)`` reset in the test
|
||||
file itself — these stay file-local because they target state specific
|
||||
to one decorator/module and globalising the reset would surprise
|
||||
unrelated tests.
|
||||
|
||||
Layout note: pure helpers (``make_*``) live alongside the fixture in this
|
||||
file rather than a sibling ``test_utils.py``. pytest's documented role for
|
||||
``conftest.py`` is fixtures, so this is a mild convention bend — kept here
|
||||
because the helper count is small and the dependencies (``tool``, ``Mock``)
|
||||
already need to be imported for the fixture itself. Split into a separate
|
||||
module if the helper count grows past ~6 or any helper picks up its own
|
||||
non-trivial dependencies."""
|
||||
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
|
||||
def make_obj(*, session_uid=None, selected=True, **attrs):
|
||||
"""Mock a ``bpy.types.Object`` with attributes commonly read by gizmos.
|
||||
|
||||
``session_uid`` is set only when provided so tests that don't care about
|
||||
object identity (most poll() tests use ``object()`` sentinels) can use
|
||||
``make_obj()`` without a spurious uid. ``selected`` wires ``select_get()``
|
||||
to return the given boolean. Extra attrs are set as plain attributes.
|
||||
|
||||
A bare ``Mock()`` is required because ``Mock(spec=bpy.types.Object)``
|
||||
rejects ``select_get`` — Blender's C-registered methods aren't exposed
|
||||
to Python introspection."""
|
||||
obj = Mock()
|
||||
if session_uid is not None:
|
||||
obj.session_uid = session_uid
|
||||
obj.select_get.return_value = selected
|
||||
for name, value in attrs.items():
|
||||
setattr(obj, name, value)
|
||||
return obj
|
||||
|
||||
|
||||
def make_element(step_id=None, *, ifc_class=None, **attrs):
|
||||
"""Mock an ``ifcopenshell.entity_instance`` with the surfaces gizmos read.
|
||||
|
||||
``step_id`` populates ``element.id()``. ``ifc_class`` wires ``is_a(name)``
|
||||
to return True only when ``name == ifc_class``. Extra kwargs become plain
|
||||
attributes (e.g. ``HasOpenings=()``)."""
|
||||
element = Mock()
|
||||
if step_id is not None:
|
||||
element.id.return_value = step_id
|
||||
if ifc_class is not None:
|
||||
element.is_a.side_effect = lambda type_name: type_name == ifc_class
|
||||
for name, value in attrs.items():
|
||||
setattr(element, name, value)
|
||||
return element
|
||||
|
||||
|
||||
def make_context(*, active=None, selected=(), scene=None):
|
||||
"""``SimpleNamespace`` stub with the ``poll()`` reads tests exercise:
|
||||
``active_object``, ``selected_objects``, and ``scene``. ``selected`` is
|
||||
materialised to a list so tests can iterate without re-walking a generator.
|
||||
``scene`` defaults to an empty namespace so guards that walk
|
||||
``context.scene.BIMPreviewProperties`` (via ``getattr(..., default=None)``)
|
||||
treat the preview as inactive — pass a custom namespace to activate."""
|
||||
return SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=list(selected),
|
||||
scene=scene if scene is not None else SimpleNamespace(),
|
||||
)
|
||||
|
||||
|
||||
def make_ifc_file(elements_by_guid: dict | None = None) -> MagicMock:
|
||||
"""Mock ``ifcopenshell.file`` with ``spec=`` so attribute typos surface as
|
||||
``AttributeError`` instead of silently auto-creating a child mock.
|
||||
|
||||
When ``elements_by_guid`` is given, ``by_guid`` is wired to look up the
|
||||
mapping and raise ``RuntimeError`` on a missing guid — same shape as the
|
||||
real ifcopenshell.file behaviour, so a test that depends on orphan handling
|
||||
sees an exception rather than a silent ``None``."""
|
||||
f = MagicMock(spec=ifcopenshell.file, name="ifc_file")
|
||||
if elements_by_guid is not None:
|
||||
|
||||
def _by_guid(guid):
|
||||
try:
|
||||
return elements_by_guid[guid]
|
||||
except KeyError:
|
||||
raise RuntimeError(f"no entity with guid {guid}")
|
||||
|
||||
f.by_guid.side_effect = _by_guid
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_tool():
|
||||
"""Context-manager factory for the ``tool.*`` boundary patches that nearly
|
||||
every gizmo / decorator test repeats. Use as::
|
||||
|
||||
with patched_tool(viewport_gizmos=True, selected=[obj_a, obj_b],
|
||||
modifier_predicates={"is_wall": True}):
|
||||
GizmoFoo.poll(context)
|
||||
|
||||
Only the kwargs you pass are patched — anything left as ``None`` (or
|
||||
omitted) keeps production behaviour. Values can be:
|
||||
|
||||
- ``viewport_gizmos`` / ``view_top_down`` / ``addon_prefs``: passed to
|
||||
``return_value=`` of the corresponding patch.
|
||||
- ``selected``: wrapped in ``set(...)`` for ``get_selected_objects``
|
||||
(matches the production return type for ``poll()``-side reads).
|
||||
- ``selected_list``: as-is for ``get_selected_objects`` when order
|
||||
matters (some operators iterate it). Mutually exclusive with
|
||||
``selected`` — if both are passed, ``selected`` wins and
|
||||
``selected_list`` is ignored. Pass only one.
|
||||
- ``entity``: either a callable (used as ``side_effect``) or a single
|
||||
value (used as ``return_value``).
|
||||
- ``modifier_predicates``: dict ``{predicate_name: bool_or_callable}``.
|
||||
Callables are wired as ``side_effect``, bools as ``return_value``.
|
||||
- ``screen_up``: ``return_value`` for ``get_screen_up_world``.
|
||||
|
||||
Patches close on context-manager exit via an ``ExitStack`` — no
|
||||
``try/finally`` bookkeeping in the test body."""
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _factory(
|
||||
*,
|
||||
viewport_gizmos=None,
|
||||
addon_prefs=None,
|
||||
selected=None,
|
||||
selected_list=None,
|
||||
entity=None,
|
||||
modifier_predicates=None,
|
||||
view_top_down=None,
|
||||
screen_up=None,
|
||||
):
|
||||
with contextlib.ExitStack() as stack:
|
||||
if viewport_gizmos is not None:
|
||||
stack.enter_context(
|
||||
patch.object(tool.Blender, "are_viewport_gizmos_enabled", return_value=viewport_gizmos)
|
||||
)
|
||||
if addon_prefs is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_addon_preferences", return_value=addon_prefs))
|
||||
if selected is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)))
|
||||
elif selected_list is not None:
|
||||
stack.enter_context(
|
||||
patch.object(tool.Blender, "get_selected_objects", return_value=list(selected_list))
|
||||
)
|
||||
if entity is not None:
|
||||
if callable(entity):
|
||||
stack.enter_context(patch.object(tool.Ifc, "get_entity", side_effect=entity))
|
||||
else:
|
||||
stack.enter_context(patch.object(tool.Ifc, "get_entity", return_value=entity))
|
||||
if modifier_predicates:
|
||||
for name, value in modifier_predicates.items():
|
||||
# Parametric feature-kind predicates live on tool.Parametric; the
|
||||
# remaining cardinality / non-parametric predicates (is_array_child,
|
||||
# is_slab, is_eligible_for_*) stay on tool.Blender.Modifier.
|
||||
target = tool.Parametric if hasattr(tool.Parametric, name) else tool.Blender.Modifier
|
||||
if callable(value):
|
||||
stack.enter_context(patch.object(target, name, side_effect=value))
|
||||
else:
|
||||
stack.enter_context(patch.object(target, name, return_value=value))
|
||||
if view_top_down is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "is_view_top_down", return_value=view_top_down))
|
||||
if screen_up is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_screen_up_world", return_value=screen_up))
|
||||
yield
|
||||
|
||||
return _factory
|
||||
@@ -0,0 +1,463 @@
|
||||
# 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.
|
||||
|
||||
"""Poll + positioning tests for ``GizmoHostAddOpening``.
|
||||
|
||||
The gizmo dispatches on element type: walls keep the existing axis-projection
|
||||
math, while LAYER3 hosts (slabs, roofs) use a world-Z face bias derived from
|
||||
the void object's elevation. Each branch is exercised independently with
|
||||
mocks so the per-type contract is pinned without launching a full Blender
|
||||
modelling session."""
|
||||
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
from test.bim.module.model.conftest import make_context
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# poll() — entry gate per host type and per co-selection shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_IFC_CLASS_BY_KIND = {
|
||||
"wall": "IfcWall",
|
||||
"slab": "IfcSlab",
|
||||
"roof": "IfcRoof",
|
||||
"plain": "IfcDiscreteAccessory",
|
||||
}
|
||||
|
||||
|
||||
class _FakeIfcEntity:
|
||||
"""Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests.
|
||||
|
||||
Provides the two surfaces the gizmo's poll consults: ``is_a(type_name)``
|
||||
(used directly by ``is_supported_host`` for slab/roof) and an optional
|
||||
``HasOpenings`` attribute (probed by the poll's ``hasattr`` guard)."""
|
||||
|
||||
def __init__(self, ifc_class: str, has_openings: bool = True):
|
||||
self._ifc_class = ifc_class
|
||||
if has_openings:
|
||||
self.HasOpenings = ()
|
||||
|
||||
def is_a(self, type_name: str) -> bool:
|
||||
return self._ifc_class == type_name
|
||||
|
||||
|
||||
def _build_poll_callbacks(selected, active_kind, other_kind):
|
||||
"""Build the ``(get_entity, is_path_connectable_wall)`` side-effect
|
||||
callables that simulate one poll() invocation. ``active_kind`` /
|
||||
``other_kind`` accept ``"wall"``, ``"slab"``, ``"roof"``, ``"plain"``
|
||||
(non-host IFC element), ``"mesh"`` (no IFC entity), or ``None``
|
||||
(object outside the selection set).
|
||||
|
||||
Wall recognition goes through ``tool.Parametric.is_path_connectable_wall``
|
||||
so fillet-corner walls (which have no LAYER2 usage) also surface the
|
||||
add-opening icon; slab/roof use ``is_a`` on the fake entity so the
|
||||
broadened class-based predicate is exercised."""
|
||||
sentinels = {kind: _FakeIfcEntity(_IFC_CLASS_BY_KIND[kind]) for kind in _IFC_CLASS_BY_KIND}
|
||||
# The "plain" sentinel lacks HasOpenings so the hasattr guard branch
|
||||
# is reachable from the corresponding poll test.
|
||||
sentinels["plain"] = _FakeIfcEntity(_IFC_CLASS_BY_KIND["plain"], has_openings=False)
|
||||
|
||||
def entity_for(kind):
|
||||
if kind in (None, "mesh"):
|
||||
return None
|
||||
return sentinels[kind]
|
||||
|
||||
entity_map = {}
|
||||
if len(selected) >= 1:
|
||||
entity_map[id(selected[0])] = entity_for(active_kind)
|
||||
if len(selected) >= 2:
|
||||
entity_map[id(selected[1])] = entity_for(other_kind)
|
||||
|
||||
def get_entity(obj):
|
||||
return entity_map.get(id(obj))
|
||||
|
||||
def is_path_connectable_wall(element):
|
||||
return element is sentinels["wall"]
|
||||
|
||||
return get_entity, is_path_connectable_wall
|
||||
|
||||
|
||||
def _run_poll(
|
||||
patched_tool, prefs_on=True, n_selected=2, active_in_selected=True, active_kind="wall", other_kind="mesh"
|
||||
):
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
selected = [object() for _ in range(n_selected)]
|
||||
active = selected[0] if (active_in_selected and selected) else object()
|
||||
get_entity, is_path_connectable_wall = _build_poll_callbacks(selected, active_kind, other_kind)
|
||||
|
||||
with patched_tool(
|
||||
viewport_gizmos=prefs_on,
|
||||
selected=selected,
|
||||
entity=get_entity,
|
||||
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
|
||||
):
|
||||
return GizmoHostAddOpening.poll(make_context(active=active, selected=selected))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host_kind", ["wall", "slab", "roof"])
|
||||
def test_poll_accepts_each_host_with_a_plain_mesh_void(host_kind, patched_tool):
|
||||
assert _run_poll(patched_tool, active_kind=host_kind, other_kind="mesh") is True
|
||||
|
||||
|
||||
def test_poll_rejects_when_gizmo_toggle_off(patched_tool):
|
||||
assert _run_poll(patched_tool, prefs_on=False) is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_selection_count_is_not_two(patched_tool):
|
||||
assert _run_poll(patched_tool, n_selected=1) is False
|
||||
assert _run_poll(patched_tool, n_selected=3) is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_is_not_in_selection(patched_tool):
|
||||
assert _run_poll(patched_tool, active_in_selected=False) is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_has_no_ifc_entity(patched_tool):
|
||||
assert _run_poll(patched_tool, active_kind="mesh") is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_is_not_a_host(patched_tool):
|
||||
# "plain" sentinel is recognised as an IFC entity but is none of wall/slab/roof.
|
||||
assert _run_poll(patched_tool, active_kind="plain") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"active_kind,other_kind",
|
||||
[
|
||||
("wall", "wall"), # wall-join gizmo owns this
|
||||
("slab", "slab"), # future slab-edit gizmo
|
||||
("roof", "roof"),
|
||||
("wall", "slab"), # extend-vertically gizmo overlaps with this
|
||||
("slab", "wall"),
|
||||
("roof", "wall"),
|
||||
],
|
||||
)
|
||||
def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool):
|
||||
"""Host + host pairings must be suppressed so the icon never stacks with
|
||||
the wall-join / extend-vertical / future slab-edit gizmos."""
|
||||
assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
|
||||
|
||||
|
||||
def test_poll_rejects_active_host_without_has_openings(patched_tool):
|
||||
# Real-world equivalent: an IFC class that the active schema strips
|
||||
# ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel
|
||||
# is set up as a connectable wall but with no HasOpenings attribute.
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
selected = [object(), object()]
|
||||
active = selected[0]
|
||||
host_sentinel = object() # No HasOpenings attribute
|
||||
other_sentinel = None
|
||||
|
||||
with patched_tool(
|
||||
viewport_gizmos=True,
|
||||
selected=selected,
|
||||
entity=lambda o: host_sentinel if o is selected[0] else other_sentinel,
|
||||
modifier_predicates={"is_path_connectable_wall": lambda e: e is host_sentinel},
|
||||
):
|
||||
assert GizmoHostAddOpening.poll(make_context(active=active, selected=selected)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# position_gizmos() — branch dispatch and per-branch anchor math
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_position_wall_branch(patched_tool, *, other_translation=(0.5, 0.0, 0.0), top_down=True):
|
||||
"""Drive the wall branch with stub IFC reads, returning the icon's
|
||||
matrix_basis translation."""
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo_module
|
||||
from bonsai.bim.module.model import host_add_opening_gizmo as host_mod
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
geom = {"anchor_x": 0.0, "length": 2.0, "height": 3.0, "offset": 0.0, "thickness": 0.2}
|
||||
wall_element = object()
|
||||
active = SimpleNamespace(matrix_world=Matrix.Identity(4))
|
||||
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector(other_translation)))
|
||||
selected = [active, other]
|
||||
context = SimpleNamespace(active_object=active)
|
||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patched_tool(
|
||||
selected_list=selected,
|
||||
entity=wall_element,
|
||||
modifier_predicates={"is_path_connectable_wall": True},
|
||||
view_top_down=top_down,
|
||||
screen_up=Vector((0.0, 1.0, 0.0)),
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(host_mod, "_get_wall_geom_cached", return_value=geom))
|
||||
stack.enter_context(patch.object(host_mod, "_wall_camera_facing_icon_y", return_value=0.0))
|
||||
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
|
||||
)
|
||||
)
|
||||
GizmoHostAddOpening.position_gizmos(self_stub, context)
|
||||
return icon.matrix_basis.translation
|
||||
|
||||
|
||||
def test_wall_branch_drops_height_lift_in_top_down_view(patched_tool):
|
||||
"""In plan view the wall-top Z lift must collapse to zero and the icon
|
||||
must instead offset along screen-up — otherwise the icon stacks on top
|
||||
of the wall outline and the user can't see it."""
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
pos = _run_position_wall_branch(patched_tool, top_down=True)
|
||||
assert pos.z == pytest.approx(0.0)
|
||||
assert pos.y == pytest.approx(BaseParametricGizmoGroup.SCREEN_STACK_OFFSET)
|
||||
|
||||
|
||||
def _run_position_layer3_branch(
|
||||
patched_tool, *, host_world_z_range=(0.0, 0.2), other_z=1.0, other_xy=(0.7, 0.4), is_path_connectable_wall=False
|
||||
):
|
||||
"""Drive the LAYER3 (slab/roof) branch and return the icon translation.
|
||||
|
||||
``host_world_z_range`` sets the world-Z extents of the host's bounding box
|
||||
(the gizmo picks top vs bottom by comparing the void's Z to the box
|
||||
midpoint). ``is_path_connectable_wall`` keeps a single helper for both
|
||||
branches by flipping the dispatch predicate."""
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo_module
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
z_min, z_max = host_world_z_range
|
||||
# bound_box returns 8 corners in local space; we only need their world-Z
|
||||
# range to drive the branch, so fix XY at zero and vary Z.
|
||||
local_corners = [(0.0, 0.0, z_min), (0.0, 0.0, z_max)] * 4
|
||||
host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=local_corners)
|
||||
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((other_xy[0], other_xy[1], other_z))))
|
||||
selected = [host_obj, other]
|
||||
context = SimpleNamespace(active_object=host_obj)
|
||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||
|
||||
host_element = object()
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patched_tool(
|
||||
selected_list=selected,
|
||||
entity=host_element,
|
||||
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
|
||||
)
|
||||
)
|
||||
GizmoHostAddOpening.position_gizmos(self_stub, context)
|
||||
return icon.matrix_basis.translation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("other_z", [1.0, 0.1, -1.0])
|
||||
def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z):
|
||||
"""Icon parks above the host's top face regardless of the void's Z —
|
||||
predictable height every time. Void's XY is preserved so clicking the
|
||||
icon dispatches the operator at the intended XY position."""
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
pos = _run_position_layer3_branch(patched_tool, host_world_z_range=(0.0, 0.2), other_z=other_z, other_xy=(0.7, 0.4))
|
||||
assert pos.x == pytest.approx(0.7)
|
||||
assert pos.y == pytest.approx(0.4)
|
||||
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_supported_host() — predicate totality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_supported_host_returns_false_for_none():
|
||||
"""Total predicate: ``None`` short-circuits to False without raising."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(None) is False
|
||||
|
||||
|
||||
def test_is_supported_host_accepts_bare_ifc_slab():
|
||||
"""The slab branch is class-based — any ``IfcSlab`` qualifies, even
|
||||
without LAYER3 parametric usage. The positioner reads ``obj.bound_box``,
|
||||
which works for both parametric and imported geometry."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(_FakeIfcEntity("IfcSlab")) is True
|
||||
|
||||
|
||||
def test_is_supported_host_accepts_bare_ifc_roof():
|
||||
"""The roof branch is class-based, not pset-based — a bare ``IfcRoof``
|
||||
imported from another IFC tool qualifies even without the Bonsai
|
||||
BBIM_Roof parametric marker that ``tool.Parametric.is_roof``
|
||||
would require."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(_FakeIfcEntity("IfcRoof")) is True
|
||||
|
||||
|
||||
def test_is_supported_host_rejects_non_host_ifc_class():
|
||||
"""Non-host IFC classes are filtered — covers ``IfcCovering`` (which has
|
||||
HasOpenings but is not a wall/slab/roof) and prevents the gizmo from
|
||||
surfacing on arbitrary building elements."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(_FakeIfcEntity("IfcCovering")) is False
|
||||
assert is_supported_host(_FakeIfcEntity("IfcDiscreteAccessory")) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end smoke: gizmo's target operator handles host + mesh-void selection
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The gizmo binds ``bim.add_opening`` via ``setup_icon_gizmo`` — clicking the
|
||||
# icon dispatches that operator with the current selection set. The operator
|
||||
# has its own target/opening detection that swaps based on which selected
|
||||
# object carries an IFC entity. This smoke test pins that handoff: with a
|
||||
# host as the active object and a non-IFC mesh as the "void", the operator
|
||||
# creates an ``IfcOpeningElement`` linked to the host via the standard
|
||||
# ``HasOpenings`` inverse.
|
||||
|
||||
|
||||
class TestAddOpeningIntegrationOnSlab(NewFile):
|
||||
def test_creates_opening_when_slab_is_active_with_mesh_void(self):
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_file = tool.Ifc.get()
|
||||
slab_type = ifc_file.by_type("IfcSlabType")[0]
|
||||
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
|
||||
slab = ifc_file.by_type("IfcSlab")[0]
|
||||
slab_obj = tool.Ifc.get_object(slab)
|
||||
assert isinstance(slab_obj, bpy.types.Object)
|
||||
assert len(slab.HasOpenings) == 0
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
slab_obj.matrix_world.translation.x,
|
||||
slab_obj.matrix_world.translation.y,
|
||||
slab_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
|
||||
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
|
||||
bpy.ops.bim.add_opening()
|
||||
|
||||
assert len(slab.HasOpenings) == 1
|
||||
opening = slab.HasOpenings[0].RelatedOpeningElement
|
||||
assert opening.is_a("IfcOpeningElement")
|
||||
|
||||
|
||||
class TestAddOpeningPollOnForeignAuthoredSlab(NewFile):
|
||||
def test_poll_resolves_true_for_slab_without_layer3_usage(self):
|
||||
"""An ``IfcSlab`` loaded from a non-Bonsai IFC carries no
|
||||
``IfcMaterialLayerSetUsage``, so ``tool.Blender.Modifier.is_slab``
|
||||
rejects it — yet the gizmo's widened predicate accepts any
|
||||
``IfcSlab`` because the positioner only reads the bound box.
|
||||
This pins the bare-class branch through the full ``poll`` path
|
||||
with real bpy + ifcopenshell state."""
|
||||
import ifcopenshell.api.material
|
||||
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import (
|
||||
GizmoHostAddOpening,
|
||||
is_supported_host,
|
||||
)
|
||||
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_file = tool.Ifc.get()
|
||||
slab_type = ifc_file.by_type("IfcSlabType")[0]
|
||||
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
|
||||
slab = ifc_file.by_type("IfcSlab")[0]
|
||||
slab_obj = tool.Ifc.get_object(slab)
|
||||
assert isinstance(slab_obj, bpy.types.Object)
|
||||
|
||||
# Strip every material association so the slab has no direct
|
||||
# LayerSetUsage and nothing to inherit from the type. The slab is
|
||||
# now a foreign-authored IFC class in everything but provenance.
|
||||
ifcopenshell.api.material.unassign_material(ifc_file, products=[slab, slab_type])
|
||||
assert tool.Blender.Modifier.is_slab(slab) is False
|
||||
assert is_supported_host(slab) is True
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
slab_obj.matrix_world.translation.x,
|
||||
slab_obj.matrix_world.translation.y,
|
||||
slab_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
|
||||
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
|
||||
assert GizmoHostAddOpening.poll(bpy.context) is True
|
||||
|
||||
|
||||
class TestAddOpeningPollOnForeignAuthoredRoof(NewFile):
|
||||
def test_poll_resolves_true_for_roof_without_bbim_pset(self):
|
||||
"""A mesh-bodied ``IfcRoof`` promoted from a raw Blender mesh
|
||||
carries no ``BBIM_Roof`` pset, so ``tool.Parametric.is_roof``
|
||||
rejects it — yet the gizmo's widened predicate accepts any
|
||||
``IfcRoof`` because the positioner only reads the bound box. This
|
||||
fixture mirrors how a foreign IFC roof loads (geometry + IFC
|
||||
identity, no parametric markers)."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import (
|
||||
GizmoHostAddOpening,
|
||||
is_supported_host,
|
||||
)
|
||||
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
|
||||
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
|
||||
roof_obj = bpy.context.active_object
|
||||
assert roof_obj is not None
|
||||
tool.Root.get_root_props().ifc_product = "IfcElement"
|
||||
bpy.ops.bim.assign_class(ifc_class="IfcRoof")
|
||||
roof = tool.Ifc.get_entity(roof_obj)
|
||||
assert roof is not None and roof.is_a("IfcRoof")
|
||||
assert tool.Parametric.is_roof(roof) is False
|
||||
assert is_supported_host(roof) is True
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
roof_obj.matrix_world.translation.x,
|
||||
roof_obj.matrix_world.translation.y,
|
||||
roof_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
|
||||
tool.Blender.set_objects_selection(bpy.context, roof_obj, (roof_obj, void_obj))
|
||||
assert GizmoHostAddOpening.poll(bpy.context) is True
|
||||
@@ -135,29 +135,30 @@ def test_every_wall_gizmo_group_resolves_get_decoration_colors():
|
||||
)
|
||||
|
||||
|
||||
def test_gizmo_wall_add_opening_accepts_fillet_corner_active():
|
||||
"""``GizmoWallAddOpening.poll`` must gate on ``is_path_connectable_wall``,
|
||||
not the strict ``is_wall`` predicate. Fillet-corner walls carry no LAYER2
|
||||
usage by IFC spec, so the strict predicate rejects them and the
|
||||
add-opening icon never surfaces over a curved corner — symmetry with the
|
||||
join / unjoin / extend wall gizmos (all of which already poll on the
|
||||
looser predicate) is required for the user to drop openings into fillet
|
||||
def test_host_add_opening_accepts_fillet_corner_active():
|
||||
"""``is_supported_host`` (the gate ``GizmoHostAddOpening.poll`` dispatches
|
||||
through) must classify walls via ``is_path_connectable_wall``, not the
|
||||
strict ``is_wall`` predicate. Fillet-corner walls carry no LAYER2 usage
|
||||
by IFC spec, so the strict predicate rejects them and the add-opening
|
||||
icon never surfaces over a curved corner — symmetry with the join /
|
||||
unjoin / extend wall gizmos (all of which already poll on the looser
|
||||
predicate) is required for the user to drop openings into fillet
|
||||
corners at all."""
|
||||
from bonsai.bim.module.model.wall import GizmoWallAddOpening
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
source = textwrap.dedent(inspect.getsource(GizmoWallAddOpening.poll))
|
||||
source = textwrap.dedent(inspect.getsource(is_supported_host))
|
||||
tree = ast.parse(source)
|
||||
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
|
||||
|
||||
assert "is_path_connectable_wall" in attr_names, (
|
||||
"GizmoWallAddOpening.poll must gate on tool.Parametric.is_path_connectable_wall "
|
||||
"for both the active element and the partner-exclusion check. The strict "
|
||||
"is_wall predicate hides the add-opening gizmo over every fillet-corner wall."
|
||||
"is_supported_host must gate walls on tool.Parametric.is_path_connectable_wall. "
|
||||
"The strict is_wall predicate hides the add-opening gizmo over every "
|
||||
"fillet-corner wall."
|
||||
)
|
||||
assert "is_wall" not in attr_names, (
|
||||
"GizmoWallAddOpening.poll must NOT call .is_wall — that strict predicate "
|
||||
"drops fillet-corner walls. Use is_path_connectable_wall instead, matching "
|
||||
"the host gate every other wall-state gizmo group uses."
|
||||
"is_supported_host must NOT call .is_wall — that strict predicate drops "
|
||||
"fillet-corner walls. Use is_path_connectable_wall instead, matching the "
|
||||
"host gate every other wall-state gizmo group uses."
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user