diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 4c6717bac7..75e2b4a46b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -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. diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index f4a97b0f21..40c6e7cd61 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -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): diff --git a/src/bonsai/bonsai/bim/module/model/roof.py b/src/bonsai/bonsai/bim/module/model/roof.py index 7c00935a02..823f95a8e1 100644 --- a/src/bonsai/bonsai/bim/module/model/roof.py +++ b/src/bonsai/bonsai/bim/module/model/roof.py @@ -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" diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index ecd50defb7..b1626e28fb 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -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( diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py b/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py index 69dea7b8c0..9efff09a52 100644 --- a/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py +++ b/src/bonsai/test/bim/module/model/test_wall_gizmos_forward_compat.py @@ -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." + ) diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py index c778b1f59f..6798515962 100644 --- a/src/bonsai/test/tool/test_model.py +++ b/src/bonsai/test/tool/test_model.py @@ -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