Promote idle-row icons into the slot system

The toggle_openings icon lived outside the IconSlot layout — each
host (wall, roof) declared an ad-hoc setup_pen_row_toggle_openings_icon
+ update_pen_row_toggle_openings_icon pair, and GizmoArrayEdition
queried a hardcoded _FEATURE_IDLE_MAX_X dict to position past it.
On an arrayed wall the dict was shadowed: find_for_element returns
"array" before "wall" in EDIT_TYPES order, the wall reservation was
never consulted, and the first per-layer ARRAY icon (local X=0.37)
landed 13cm from the wall's toggle_openings (X=0.50) — visually on
top of each other.

Promote idle-row icons into the slot system instead of patching the
dict:

* IconSlot gains an Optional visible_when predicate for state-driven
  visibility (toggle_openings only when the host carries openings).
* BaseParametricGizmoGroup gains idle_slots: ClassVar[tuple[IconSlot]]
  + _idle_slot_x_positions() + _idle_row_right_edge() helpers; the
  setup + idle-branch positioning loops mirror the existing
  feature_slots path.
* Wall and roof declare toggle_openings as an idle_slot and drop
  their ad-hoc setup/update calls.
* GizmoArrayEdition's _resolve_feature_idle_max_x walks
  BaseParametricGizmoGroup.REGISTRY and takes the max
  _idle_row_right_edge() across peers whose poll passes — no more
  hardcoded dict, no more find_for_element-order shadowing.
* setup_pen_row_toggle_openings_icon + update_pen_row_toggle_openings_icon
  helpers deleted from drawing/gizmos.py.
* 3 forward-compat AST guards pin the new contract.

Also bundles an unrelated array-test fix: TestUsingArrays in
test/tool/test_model.py was asserting against bpy.context.selected_objects
which is a fragile signal after remove_array / apply_array. A new
_array_objects() helper filters bpy.data.objects via the BIM_Array
pset's IfcActuator type instead.

Layout on an arrayed wall after the fix:
  pen        X = 0.00
  toggle     X = 0.50 (idle_slot 0)
  array[0]   X = 0.87 (one ICON_ARRAY_GAP past idle row)
  array[1]   X = 1.27
All separated by the standard inter-icon spacing.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-06 18:22:27 +02:00
committed by Thomas Krijnen
parent b873db11db
commit 99d758a330
6 changed files with 360 additions and 112 deletions
+87 -40
View File
@@ -82,7 +82,7 @@ import math
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from enum import Enum
from typing import Any, ClassVar, Literal, Protocol, runtime_checkable
from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable
import blf
import bpy
@@ -5083,6 +5083,12 @@ class IconSlot:
extra_gap_before: float = 0.0
operator_props: tuple[tuple[str, Any], ...] = ()
placeholder: bool = False
# Optional per-frame visibility predicate. Called with the gizmo group
# instance as the sole argument; returning False hides this slot's gizmo
# while still reserving its X position so the row layout doesn't shift.
# Used for idle-row icons whose relevance depends on element state (e.g.
# toggle_openings only when the host has openings).
visible_when: Optional[Callable[[Any], bool]] = None
def __post_init__(self) -> None:
# Validate shape at class-definition time so a typo doesn't surface
@@ -5235,6 +5241,13 @@ class BaseParametricGizmoGroup:
# constant, no "remember to bump the right edge" rule. The trailing
# ARRAY button is positioned past the last slot automatically.
feature_slots: ClassVar[tuple[IconSlot, ...]] = ()
# Idle-mode pen-row extras (e.g. wall's toggle_openings). Each slot is
# placed past the pen at uniform ``ICON_ARRAY_GAP`` spacing. Hidden during
# edit — the validate/cancel row owns the X positions there. Peer gizmo
# groups (e.g. ``GizmoArrayEdition``'s per-layer ARRAY icons) query
# ``_idle_row_right_edge()`` to position past these without a hardcoded
# per-feature table.
idle_slots: ClassVar[tuple[IconSlot, ...]] = ()
# Gap between adjacent slots past the leading validate/cancel/cycle
# triplet, AND between the last slot and the ARRAY button.
ICON_ARRAY_GAP: float = 0.37
@@ -5289,6 +5302,31 @@ class BaseParametricGizmoGroup:
return cls.ICON_CYCLE_X
return max(positions.values())
@classmethod
def _idle_slot_x_positions(cls) -> dict[str, float]:
"""Map each ``idle_slot`` name to its X coordinate past the pen.
First idle slot lands at ``ICON_CANCEL_X`` (the cancel-slot position,
unused in idle since validate/cancel are edit-only). Successive slots
are spaced by ``ICON_ARRAY_GAP``, plus any per-slot ``extra_gap_before``."""
positions: dict[str, float] = {}
next_x = cls.ICON_CANCEL_X
for slot in cls.idle_slots:
next_x += slot.extra_gap_before
positions[slot.name] = next_x
next_x += cls.ICON_ARRAY_GAP
return positions
@classmethod
def _idle_row_right_edge(cls) -> float:
"""Rightmost local-X reserved by this group's idle row. Returns the
pen position (``ICON_VALIDATE_X``) when no idle slots are declared
so peer queries always get a meaningful number."""
positions = cls._idle_slot_x_positions()
if not positions:
return cls.ICON_VALIDATE_X
return max(positions.values())
@classmethod
def pick_visible_anchor(cls, context: bpy.types.Context, world_base: Vector, world_top: Vector) -> Vector:
"""Choose between two anchor candidates so vertical separation stays
@@ -5761,45 +5799,6 @@ 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.
@@ -6019,6 +6018,19 @@ class BaseParametricGizmoGroup:
gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs)
setattr(self, attr, gz)
# Idle-mode pen-row extras. Same creation path as feature_slots; the
# IDLE branch of ``update_editing_gizmos`` positions and visibility-
# gates them, the EDIT branch hides them so the validate/cancel row
# owns the X positions.
for slot in self.idle_slots:
if slot.placeholder:
continue
slot_color = slot.color if slot.color is not None else default_color
kwargs = dict(slot.operator_props)
for attr, idname in zip(slot.gizmo_attrs(), slot.variant_idnames()):
gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs)
setattr(self, attr, gz)
# ARRAY button — visible during the feature edit lifecycle only (positioned by
# ``update_editing_gizmos``). Click commits the current edit and adds a
# Blender-vanilla-defaulted array (count=2, X-offset = bbox extent). The
@@ -6333,6 +6345,13 @@ class BaseParametricGizmoGroup:
billboard_rot=billboard_rot,
scale=0.35,
)
# Idle-row icons are hidden in edit — validate / cancel sit at
# the same X positions, so showing both would stack icons.
for slot in self.idle_slots:
for attr in slot.gizmo_attrs():
gz = getattr(self, attr, None)
if gz is not None:
gz.hide = True
else:
# ``hide_pen_button = True`` keeps the pen permanently hidden — for
# groups whose edit-mode entry is already provided by another widget
@@ -6358,6 +6377,34 @@ class BaseParametricGizmoGroup:
gz.hide = True
if hasattr(self, "array_gizmo"):
self.array_gizmo.hide = True
# Idle slots: position past the pen, apply per-slot visible_when
# so state-dependent icons (e.g. toggle_openings) only render
# when relevant. Hidden slots STILL consume their X position so
# the row layout doesn't shift when state flips.
idle_positions = self._idle_slot_x_positions()
for slot in self.idle_slots:
if slot.placeholder:
continue
slot_x = self.ICON_VALIDATE_X + idle_positions[slot.name]
gate = slot.visible_when
visible = True if gate is None else bool(gate(self))
for attr in slot.gizmo_attrs():
gz = getattr(self, attr, None)
if gz is None:
continue
if not visible:
gz.hide = True
continue
gz.hide = self.is_gizmo_hidden_by_modal(gz)
self.set_icon_gizmo_position(
attr,
mw=mw,
x=slot_x,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
scale=slot.scale,
)
def draw_prepare(self, context: bpy.types.Context) -> None:
"""Called before drawing - updates gizmos to face camera.
+26 -32
View File
@@ -1086,21 +1086,11 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
# per-item panel UX for the overflow layers.
MAX_LAYER_GIZMOS = 8
# Local-X spacing between successive layer icons. The start position
# of the first icon is feature-aware (see ``_resolve_feature_idle_max_x``)
# so it doesn't collide with feature-specific idle gizmos (e.g. wall's
# toggle-openings + offset-baseline icons that share the row).
# is computed per-frame from peer parametric gizmo groups' idle rows
# (see ``_resolve_feature_idle_max_x``) so layer icons clear any
# feature-specific idle slots (e.g. wall's toggle-openings).
LAYER_GIZMO_SPACING = 0.4
# Per-feature idle-state rightmost icon X. Layer icons start past this
# so they don't collide with feature-specific idle gizmos. Centralised
# here (rather than declared per-feature) because the array group is
# the consumer and this knowledge is local to its layout decision.
# Door / window / stair / roof / railing have no idle icons past the
# pen, so they default to 0.0.
_FEATURE_IDLE_MAX_X: ClassVar[dict[str, float]] = {
"wall": 0.87, # past offset_baseline (EXT/CEN/INT share the cycle slot)
}
dimension_gizmo_props = [
# matrix_position must be provided even at the origin: without it, the
# base class falls back to ``Matrix.Identity(4)`` for ``base_matrix``,
@@ -1329,29 +1319,33 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
@classmethod
def _resolve_feature_idle_max_x(cls, context: bpy.types.Context) -> float:
"""Return the rightmost local-X used by the active element's matching
per-feature gizmo group in idle state. Per-layer ARRAY icons start
one ``ICON_ARRAY_GAP`` past this so they don't stack on top of any
feature-specific idle icons.
"""Rightmost local-X reserved by any peer ``BaseParametricGizmoGroup``
whose ``poll`` passes on the active element. Per-layer ARRAY icons
start one ``ICON_ARRAY_GAP`` past this so they don't stack on top of
feature-specific idle slots (e.g. wall's toggle_openings).
Resolves the active element's type via ``tool.Parametric.find_for_element``
(registry lookup, see [tool/parametric.py:321]) and indexes into the
local ``_FEATURE_IDLE_MAX_X`` table. Defaults to 0.0 when:
- no active object
- object isn't an IFC element
- element doesn't match any registry type
- matched type is ``"array"`` (no per-feature gizmo group to dodge)
- matched type has no entry in the table"""
Walks ``BaseParametricGizmoGroup.REGISTRY`` rather than indexing a
hardcoded per-feature table: each peer's ``_idle_row_right_edge()``
derives from its declared ``idle_slots`` tuple, so adding an idle
icon to any feature is a one-line ``IconSlot`` append with no
coordination needed here. Defaults to 0.0 when no active object or
no peer polls visible."""
obj = context.active_object
if obj is None:
return 0.0
element = tool.Ifc.get_entity(obj)
if element is None:
return 0.0
match = tool.Parametric.find_for_element(element)
if match is None or match.name == "array":
return 0.0
return cls._FEATURE_IDLE_MAX_X.get(match.name, 0.0)
max_x = 0.0
for peer_cls in gizmo.BaseParametricGizmoGroup.REGISTRY:
if peer_cls is cls:
continue
try:
if not peer_cls.poll(context):
continue
except Exception:
continue
edge = peer_cls._idle_row_right_edge()
if edge > max_x:
max_x = edge
return max_x
class GizmoArrayChild(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
+23 -11
View File
@@ -18,7 +18,7 @@
import json
from math import atan2, cos, degrees, pi, radians, tan
from typing import Any, Literal, Union
from typing import Any, ClassVar, Literal, Union
import bmesh
import bpy
@@ -33,7 +33,7 @@ from mathutils import Quaternion, Vector
import bonsai.core.root
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot
from bonsai.bim.module.model.data import RoofData, refresh
from bonsai.bim.module.model.decorator import ProfileDecorator
from bonsai.bim.parametric_lifecycle import CycleTypeMixin, PathPreservingEditMixin
@@ -678,6 +678,18 @@ _ROOF_SLOPE_REFERENCE_RUN = 1.0
_ROOF_MAX_SLOPE_ANGLE = pi / 2 - 0.001
def _roof_has_openings() -> bool:
"""``visible_when`` predicate for the toggle_openings idle slot. True iff
the active object's IFC element exposes a non-empty HasOpenings inverse."""
obj = bpy.context.active_object
if obj is None:
return False
element = tool.Ifc.get_entity(obj)
if element is None:
return False
return tool.Geometry.has_openings(element)
class CycleRoofGenerationMethod(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
"""Cycle the roof generation method (HEIGHT ↔ ANGLE). Shift+click cycles in reverse."""
@@ -740,6 +752,15 @@ class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
props_getter = tool.Model.get_roof_props
gizmo_pref_name = "roof"
idle_slots: ClassVar[tuple[IconSlot, ...]] = (
IconSlot(
name="toggle_openings",
gizmo_idname="VIEW3D_GT_add_opening",
operator="bim.toggle_host_openings",
visible_when=lambda gg: _roof_has_openings(),
),
)
@classmethod
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
return tool.Parametric.is_roof(element)
@@ -765,15 +786,6 @@ class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
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"
+44 -26
View File
@@ -90,6 +90,19 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
return True
def _wall_has_openings(gz_group: bpy.types.GizmoGroup) -> bool:
"""``visible_when`` predicate for the toggle_openings idle slot. Returns
True iff the active object's IFC element exposes a non-empty HasOpenings
inverse — keeps the toggle hidden on walls that carry no opening cuts."""
obj = bpy.context.active_object
if obj is None:
return False
element = tool.Ifc.get_entity(obj)
if element is None:
return False
return tool.Geometry.has_openings(element)
def regenerate_wall_mesh_from_props(obj: bpy.types.Object) -> None:
"""Rebuild ``obj.data`` as a preview box from ``BIMWallProperties`` without touching IFC.
@@ -421,11 +434,11 @@ class ExtendWallsToPolylinePoint(bpy.types.Operator, PolylineOperator, tool.Ifc.
def set_origin(self, context, event, connection="ATSTART"):
obj = context.active_object
element = tool.Ifc.get_entity(obj)
layers = tool.Model.get_material_layer_parameters(element)
axis = tool.Model.get_wall_axis(obj, layers)
start = Vector((axis["reference"][0][0], axis["reference"][0][1], obj.location.z))
end = Vector((axis["reference"][1][0], axis["reference"][1][1], obj.location.z))
ref = tool.Wall.get_world_reference_line(obj)
if ref is None:
return
start = Vector((ref[0].x, ref[0].y, obj.location.z))
end = Vector((ref[1].x, ref[1].y, obj.location.z))
direcion = end - start
value = end if connection == "ATSTART" else start
self.input_ui.set_value("X", value[0])
@@ -1424,8 +1437,11 @@ class DumbWallJoiner:
if tool.Ifc.is_moved(wall1):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1)
axis1 = tool.Model.get_wall_axis(wall1)
intersect, cut_percentage = mathutils.geometry.intersect_point_line(target.to_2d(), *axis1["reference"])
ref = tool.Wall.get_world_reference_line(wall1)
if ref is None:
return
axis_world_2d = (ref[0].to_2d(), ref[1].to_2d())
intersect, cut_percentage = mathutils.geometry.intersect_point_line(target.to_2d(), *axis_world_2d)
if cut_percentage < 0 or cut_percentage > 1 or tool.Cad.is_x(cut_percentage, (0, 1)):
return
@@ -1469,7 +1485,7 @@ class DumbWallJoiner:
for opening in [
r.RelatedOpeningElement for r in element1.HasOpenings if not r.RelatedOpeningElement.HasFillings
]:
min_t, _ = _opening_axis_extent(opening, axis1["reference"], unit_scale)
min_t, _ = _opening_axis_extent(opening, axis_world_2d, unit_scale)
if min_t > cut_percentage:
# Opening lies entirely past the cut — only element2 should keep it.
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
@@ -1477,7 +1493,7 @@ class DumbWallJoiner:
for opening in [
r.RelatedOpeningElement for r in element2.HasOpenings if not r.RelatedOpeningElement.HasFillings
]:
_, max_t = _opening_axis_extent(opening, axis1["reference"], unit_scale)
_, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale)
if max_t < cut_percentage:
# Opening lies entirely before the cut — only element1 should keep it.
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
@@ -1494,8 +1510,8 @@ class DumbWallJoiner:
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(), *axis1["reference"])
min_t, max_t = _opening_axis_extent(opening, axis1["reference"], unit_scale)
_, 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)
void_straddles = min_t < cut_percentage < max_t
if filling_position > cut_percentage:
# The filling should be moved from element1 to element2.
@@ -2063,6 +2079,18 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
),
)
# Idle-mode pen-row extras. The base class handles setup + per-frame
# positioning + visibility gating via ``visible_when``; this declaration
# is the only wall-specific code needed for the toggle-openings icon.
idle_slots: ClassVar[tuple[IconSlot, ...]] = (
IconSlot(
name="toggle_openings",
gizmo_idname="VIEW3D_GT_add_opening",
operator="bim.toggle_host_openings",
visible_when=lambda gg: _wall_has_openings(gg),
),
)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Wall-specific gizmos.
@@ -2076,15 +2104,11 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
wall top (Z=height in wall-local). Clicking extends the wall's height to
the cursor's Z.
Idle-row icon outside the toolbar slot system:
- ``toggle_openings_gizmo`` — toggles opening fill visibility (Alt+O),
surfaced in idle state next to the pen.
The baseline-state triplet (exterior/center/interior) and the rotate-90
icon live in ``feature_slots`` — the base class handles creation and
edit-row positioning; this group only picks variant visibility per
frame in ``_update_icon_row_extras``."""
frame in ``_update_icon_row_extras``. The idle-row ``toggle_openings``
icon is declared in ``idle_slots`` and fully managed by the base."""
default_color, highlight_color = self.get_decoration_colors()
self.split_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_split",
@@ -2104,7 +2128,6 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"bim.extend_wall_height_to_cursor",
highlight_color,
)
self.setup_pen_row_toggle_openings_icon()
if context.region is not None:
type(self)._active_instances[context.region.as_pointer()] = weakref.ref(self)
@@ -2202,19 +2225,15 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
}
def _update_icon_row_extras(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None:
"""Pick which baseline variant is visible during edit, and position
the idle-row toggle-openings icon.
"""Pick which baseline variant is visible during edit.
Baseline triplet: the base class's slot loop already wrote a billboard
matrix on each variant member at the same X (the cycle slot, since
wall has no ``cycle_type_operator``). This hook only flips ``hide``
on each member based on ``props.desired_offset_baseline`` so exactly
one variant shows. The rotate-90 icon is a single-icon feature slot
and is fully handled by the base.
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
via the base's shared helper here."""
and is fully handled by the base. The toggle-openings idle icon is
declared in ``idle_slots`` and positioned by the base."""
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)
@@ -2224,7 +2243,6 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
gz.hide = self.is_gizmo_hidden_by_modal(gz)
else:
gz.hide = True
self.update_pen_row_toggle_openings_icon(context, mw, props)
def _apply_wall_extend_flips(
@@ -211,3 +211,176 @@ def test_join_intersection_stacks_along_screen_up_in_both_states():
"billboarded_at writes for the join/unjoin/extend/fillet icons bypass "
"the stacking contract and re-introduce the top-view collapse bug."
)
def _get_wall_axis_callers_in(method) -> set[str]:
"""Return the set of attribute chains in ``method``'s source that resolve
to ``tool.Model.get_wall_axis``. Empty set means the method does not read
from the mesh-bound-box axis source."""
source = textwrap.dedent(inspect.getsource(method))
tree = ast.parse(source)
offenders: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if not isinstance(func, ast.Attribute) or func.attr != "get_wall_axis":
continue
# Reconstruct the receiver chain to surface it in the assertion message.
chain: list[str] = [func.attr]
receiver = func.value
while isinstance(receiver, ast.Attribute):
chain.append(receiver.attr)
receiver = receiver.value
if isinstance(receiver, ast.Name):
chain.append(receiver.id)
offenders.add(".".join(reversed(chain)))
return offenders
def _method_writes_ifc_axis(method) -> bool:
"""True iff ``method``'s body calls ``self.set_axis(...)`` — the only
path that writes a wall's IFC reference line via
``ifcopenshell.api.geometry.assign_representation``. Methods that only
read ``axis["base"]`` / ``axis["side"]`` for layer-polygon work (slab
clipping, opening snap) never call ``set_axis`` and are not under this
rule."""
source = textwrap.dedent(inspect.getsource(method))
tree = ast.parse(source)
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if isinstance(func, ast.Attribute) and func.attr == "set_axis":
return True
return False
def test_dumb_wall_joiner_axis_writers_read_ifc_reference_line():
"""Any ``DumbWallJoiner`` method that writes the IFC reference line
(via ``self.set_axis`` ``ifcopenshell.api.geometry.assign_representation``)
must read its input axis from the IFC reference line too not from
``tool.Model.get_wall_axis``, whose X-extent comes from ``obj.bound_box``
(the Body mesh AABB). The bound-box axis drifts past or short of the
IFC reference line at mitred / butt-jointed walls and at walls with end
openings; mixing it on input with the IFC axis on output produces
non-colinear sub-axes that compound through chained extend/split/join
edits.
The IFC-anchored helper is ``tool.Wall.get_world_reference_line`` for
world-space endpoints, or ``ifcopenshell.util.representation.get_reference_line``
for local-SI endpoints.
Joiner methods that only read layer-polygon base/side (e.g. ``clip``
for slab intersection) are exempt they need the body footprint, not
the axis, and never call ``set_axis``."""
from bonsai.bim.module.model.wall import DumbWallJoiner
offenders: dict[str, set[str]] = {}
for name, method in inspect.getmembers(DumbWallJoiner, predicate=inspect.isfunction):
if not _method_writes_ifc_axis(method):
continue
bad_calls = _get_wall_axis_callers_in(method)
if bad_calls:
offenders[name] = bad_calls
assert not offenders, (
f"DumbWallJoiner methods that call self.set_axis must not read the "
f"bound-box-derived axis: {offenders}. Use "
"tool.Wall.get_world_reference_line for world-space endpoints, or "
"ifcopenshell.util.representation.get_reference_line for local-SI "
"endpoints. Mixing bound_box on input with IFC axis on output "
"produces non-colinear sub-axes that compound through chained "
"extend/split/join edits."
)
def test_extend_walls_to_polyline_set_origin_uses_ifc_reference_line():
"""``ExtendWallsToPolylinePoint.set_origin`` seeds the polyline preview
anchor at one of the wall's axis endpoints. The downstream operator
(``DumbWallJoiner.extend``) projects the user's chosen target onto the
IFC reference line; if the preview anchor comes from
``tool.Model.get_wall_axis`` (bound_box) the user sees the preview at
one endpoint and the wall lands at a different one the visible
"extend falls short by a few cm/m" symptom."""
from bonsai.bim.module.model.wall import ExtendWallsToPolylinePoint
offenders = _get_wall_axis_callers_in(ExtendWallsToPolylinePoint.set_origin)
assert not offenders, (
f"ExtendWallsToPolylinePoint.set_origin must not read the bound-box-derived "
f"axis: {offenders}. Use tool.Wall.get_world_reference_line so the preview "
"anchor lands on the same IFC reference line the downstream extend operator "
"projects onto."
)
def test_wall_toggle_openings_uses_idle_slots():
"""The wall's toggle_openings icon must be declared in
``GizmoWallEdition.idle_slots`` so the base class lays it out at the
standard pen-row position. Routing it through ad-hoc setup helpers
instead would re-introduce the X-collision with the array's first
per-layer icon the bug this contract was added to prevent."""
from bonsai.bim.module.model.wall import GizmoWallEdition
slot_names = {s.name for s in GizmoWallEdition.idle_slots}
assert "toggle_openings" in slot_names, (
"GizmoWallEdition.idle_slots must contain a slot named 'toggle_openings'. "
"The base class derives its X position from the slot's tuple index so peer "
"groups (GizmoArrayEdition's per-layer icons) can query a real layout edge "
"via _idle_row_right_edge() instead of a hardcoded per-feature table."
)
def test_no_pen_row_toggle_openings_helpers_remain():
"""The legacy ``setup_pen_row_toggle_openings_icon`` and
``update_pen_row_toggle_openings_icon`` helpers were removed once
toggle_openings migrated into the ``idle_slots`` system. A re-introduced
helper would shadow the slot-driven layout features calling it would
set up a second gizmo at a different X and the collision-prevention
contract would silently regress.
Walks the wall and roof modules (the historical callers) plus
drawing/gizmos.py (the historical home) for any reference to either
name."""
import bonsai.bim.module.drawing.gizmos as gizmos_mod
import bonsai.bim.module.model.roof as roof_mod
import bonsai.bim.module.model.wall as wall_mod
forbidden = ("setup_pen_row_toggle_openings_icon", "update_pen_row_toggle_openings_icon")
for mod in (gizmos_mod, roof_mod, wall_mod):
source = inspect.getsource(mod)
for name in forbidden:
assert name not in source, (
f"{mod.__name__} still references {name!r}. The toggle_openings icon "
f"is now declared via idle_slots; the ad-hoc helpers were removed to "
f"prevent layout drift between feature groups."
)
def test_array_idle_max_x_walks_registry_not_hardcoded_dict():
"""``GizmoArrayEdition._resolve_feature_idle_max_x`` must query peer
parametric gizmo groups' ``_idle_row_right_edge`` rather than indexing
a hardcoded per-feature ``_FEATURE_IDLE_MAX_X`` dict. The dict approach
was the source of the toggle_openings array-layer-icon collision bug
on arrayed walls (find_for_element returns 'array' first, shadowing the
wall reservation)."""
from bonsai.bim.module.model.array import GizmoArrayEdition
assert not hasattr(GizmoArrayEdition, "_FEATURE_IDLE_MAX_X"), (
"GizmoArrayEdition._FEATURE_IDLE_MAX_X was a hardcoded per-feature dict "
"that shadowed peer groups' real idle rows for compound elements (arrayed "
"walls). It was replaced by a registry walk via REGISTRY + "
"_idle_row_right_edge() — re-introducing the dict would re-create the bug."
)
source = inspect.getsource(GizmoArrayEdition._resolve_feature_idle_max_x)
assert "_idle_row_right_edge" in source, (
"_resolve_feature_idle_max_x must call peer_cls._idle_row_right_edge() so "
"the X position derives from each peer's actual declared idle_slots."
)
assert "REGISTRY" in source, (
"_resolve_feature_idle_max_x must iterate BaseParametricGizmoGroup.REGISTRY "
"to discover peer groups; find_for_element returns ONE entry and shadows "
"compound-element memberships."
)
+7 -3
View File
@@ -588,6 +588,10 @@ class TestGenerateStair2DProfile(NewFile):
class TestUsingArrays(NewFile):
@staticmethod
def _array_objects() -> list[bpy.types.Object]:
return [o for o in bpy.data.objects if (e := tool.Ifc.get_entity(o)) and e.is_a("IfcActuator")]
def setup_array(self, add_second_layer=False, sync_children=False):
tool.Project.get_project_props().template_file = "0"
bpy.ops.bim.create_project()
@@ -619,9 +623,9 @@ class TestUsingArrays(NewFile):
def test_remove_array_last_to_first(self):
self.setup_array(add_second_layer=True)
bpy.ops.bim.remove_array(item=1)
assert len(bpy.context.selected_objects) == 4
assert len(self._array_objects()) == 4
bpy.ops.bim.remove_array(item=0)
assert len(bpy.context.selected_objects) == 1
assert len(self._array_objects()) == 1
def test_remove_array_first_to_last(self):
self.setup_array(add_second_layer=True)
@@ -647,7 +651,7 @@ class TestUsingArrays(NewFile):
bpy.ops.bim.apply_array() # apply second layer
bpy.ops.bim.apply_array() # apply first layer
objs = bpy.context.selected_objects
objs = self._array_objects()
assert len(objs) == 12
# check BBIM_Array psets are removed