mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-29 11:46:33 +00:00
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:
@@ -82,7 +82,7 @@ import math
|
|||||||
from collections.abc import Callable, Iterator
|
from collections.abc import Callable, Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
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 blf
|
||||||
import bpy
|
import bpy
|
||||||
@@ -5083,6 +5083,12 @@ class IconSlot:
|
|||||||
extra_gap_before: float = 0.0
|
extra_gap_before: float = 0.0
|
||||||
operator_props: tuple[tuple[str, Any], ...] = ()
|
operator_props: tuple[tuple[str, Any], ...] = ()
|
||||||
placeholder: bool = False
|
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:
|
def __post_init__(self) -> None:
|
||||||
# Validate shape at class-definition time so a typo doesn't surface
|
# 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
|
# constant, no "remember to bump the right edge" rule. The trailing
|
||||||
# ARRAY button is positioned past the last slot automatically.
|
# ARRAY button is positioned past the last slot automatically.
|
||||||
feature_slots: ClassVar[tuple[IconSlot, ...]] = ()
|
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
|
# Gap between adjacent slots past the leading validate/cancel/cycle
|
||||||
# triplet, AND between the last slot and the ARRAY button.
|
# triplet, AND between the last slot and the ARRAY button.
|
||||||
ICON_ARRAY_GAP: float = 0.37
|
ICON_ARRAY_GAP: float = 0.37
|
||||||
@@ -5289,6 +5302,31 @@ class BaseParametricGizmoGroup:
|
|||||||
return cls.ICON_CYCLE_X
|
return cls.ICON_CYCLE_X
|
||||||
return max(positions.values())
|
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
|
@classmethod
|
||||||
def pick_visible_anchor(cls, context: bpy.types.Context, world_base: Vector, world_top: Vector) -> Vector:
|
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
|
"""Choose between two anchor candidates so vertical separation stays
|
||||||
@@ -5761,45 +5799,6 @@ class BaseParametricGizmoGroup:
|
|||||||
def get_element_height(self, props) -> float:
|
def get_element_height(self, props) -> float:
|
||||||
return getattr(props, "overall_height", getattr(props, "height", 1.0))
|
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:
|
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.
|
"""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)
|
gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs)
|
||||||
setattr(self, attr, gz)
|
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
|
# ARRAY button — visible during the feature edit lifecycle only (positioned by
|
||||||
# ``update_editing_gizmos``). Click commits the current edit and adds a
|
# ``update_editing_gizmos``). Click commits the current edit and adds a
|
||||||
# Blender-vanilla-defaulted array (count=2, X-offset = bbox extent). The
|
# Blender-vanilla-defaulted array (count=2, X-offset = bbox extent). The
|
||||||
@@ -6333,6 +6345,13 @@ class BaseParametricGizmoGroup:
|
|||||||
billboard_rot=billboard_rot,
|
billboard_rot=billboard_rot,
|
||||||
scale=0.35,
|
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:
|
else:
|
||||||
# ``hide_pen_button = True`` keeps the pen permanently hidden — for
|
# ``hide_pen_button = True`` keeps the pen permanently hidden — for
|
||||||
# groups whose edit-mode entry is already provided by another widget
|
# groups whose edit-mode entry is already provided by another widget
|
||||||
@@ -6358,6 +6377,34 @@ class BaseParametricGizmoGroup:
|
|||||||
gz.hide = True
|
gz.hide = True
|
||||||
if hasattr(self, "array_gizmo"):
|
if hasattr(self, "array_gizmo"):
|
||||||
self.array_gizmo.hide = True
|
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:
|
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||||
"""Called before drawing - updates gizmos to face camera.
|
"""Called before drawing - updates gizmos to face camera.
|
||||||
|
|||||||
@@ -1086,21 +1086,11 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
|||||||
# per-item panel UX for the overflow layers.
|
# per-item panel UX for the overflow layers.
|
||||||
MAX_LAYER_GIZMOS = 8
|
MAX_LAYER_GIZMOS = 8
|
||||||
# Local-X spacing between successive layer icons. The start position
|
# Local-X spacing between successive layer icons. The start position
|
||||||
# of the first icon is feature-aware (see ``_resolve_feature_idle_max_x``)
|
# is computed per-frame from peer parametric gizmo groups' idle rows
|
||||||
# so it doesn't collide with feature-specific idle gizmos (e.g. wall's
|
# (see ``_resolve_feature_idle_max_x``) so layer icons clear any
|
||||||
# toggle-openings + offset-baseline icons that share the row).
|
# feature-specific idle slots (e.g. wall's toggle-openings).
|
||||||
LAYER_GIZMO_SPACING = 0.4
|
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 = [
|
dimension_gizmo_props = [
|
||||||
# matrix_position must be provided even at the origin: without it, the
|
# matrix_position must be provided even at the origin: without it, the
|
||||||
# base class falls back to ``Matrix.Identity(4)`` for ``base_matrix``,
|
# base class falls back to ``Matrix.Identity(4)`` for ``base_matrix``,
|
||||||
@@ -1329,29 +1319,33 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _resolve_feature_idle_max_x(cls, context: bpy.types.Context) -> float:
|
def _resolve_feature_idle_max_x(cls, context: bpy.types.Context) -> float:
|
||||||
"""Return the rightmost local-X used by the active element's matching
|
"""Rightmost local-X reserved by any peer ``BaseParametricGizmoGroup``
|
||||||
per-feature gizmo group in idle state. Per-layer ARRAY icons start
|
whose ``poll`` passes on the active element. Per-layer ARRAY icons
|
||||||
one ``ICON_ARRAY_GAP`` past this so they don't stack on top of any
|
start one ``ICON_ARRAY_GAP`` past this so they don't stack on top of
|
||||||
feature-specific idle icons.
|
feature-specific idle slots (e.g. wall's toggle_openings).
|
||||||
|
|
||||||
Resolves the active element's type via ``tool.Parametric.find_for_element``
|
Walks ``BaseParametricGizmoGroup.REGISTRY`` rather than indexing a
|
||||||
(registry lookup, see [tool/parametric.py:321]) and indexes into the
|
hardcoded per-feature table: each peer's ``_idle_row_right_edge()``
|
||||||
local ``_FEATURE_IDLE_MAX_X`` table. Defaults to 0.0 when:
|
derives from its declared ``idle_slots`` tuple, so adding an idle
|
||||||
- no active object
|
icon to any feature is a one-line ``IconSlot`` append with no
|
||||||
- object isn't an IFC element
|
coordination needed here. Defaults to 0.0 when no active object or
|
||||||
- element doesn't match any registry type
|
no peer polls visible."""
|
||||||
- matched type is ``"array"`` (no per-feature gizmo group to dodge)
|
|
||||||
- matched type has no entry in the table"""
|
|
||||||
obj = context.active_object
|
obj = context.active_object
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return 0.0
|
return 0.0
|
||||||
element = tool.Ifc.get_entity(obj)
|
max_x = 0.0
|
||||||
if element is None:
|
for peer_cls in gizmo.BaseParametricGizmoGroup.REGISTRY:
|
||||||
return 0.0
|
if peer_cls is cls:
|
||||||
match = tool.Parametric.find_for_element(element)
|
continue
|
||||||
if match is None or match.name == "array":
|
try:
|
||||||
return 0.0
|
if not peer_cls.poll(context):
|
||||||
return cls._FEATURE_IDLE_MAX_X.get(match.name, 0.0)
|
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):
|
class GizmoArrayChild(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from math import atan2, cos, degrees, pi, radians, tan
|
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 bmesh
|
||||||
import bpy
|
import bpy
|
||||||
@@ -33,7 +33,7 @@ from mathutils import Quaternion, Vector
|
|||||||
import bonsai.core.root
|
import bonsai.core.root
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
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.data import RoofData, refresh
|
||||||
from bonsai.bim.module.model.decorator import ProfileDecorator
|
from bonsai.bim.module.model.decorator import ProfileDecorator
|
||||||
from bonsai.bim.parametric_lifecycle import CycleTypeMixin, PathPreservingEditMixin
|
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
|
_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):
|
class CycleRoofGenerationMethod(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
|
||||||
"""Cycle the roof generation method (HEIGHT ↔ ANGLE). Shift+click cycles in reverse."""
|
"""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
|
props_getter = tool.Model.get_roof_props
|
||||||
gizmo_pref_name = "roof"
|
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
|
@classmethod
|
||||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||||
return tool.Parametric.is_roof(element)
|
return tool.Parametric.is_roof(element)
|
||||||
@@ -765,15 +786,6 @@ class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
|||||||
return 1.0
|
return 1.0
|
||||||
return max(c[2] for c in obj.bound_box)
|
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):
|
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
|
||||||
bl_idname = "bim.enable_editing_roof_path"
|
bl_idname = "bim.enable_editing_roof_path"
|
||||||
|
|||||||
@@ -90,6 +90,19 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
|
|||||||
return True
|
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:
|
def regenerate_wall_mesh_from_props(obj: bpy.types.Object) -> None:
|
||||||
"""Rebuild ``obj.data`` as a preview box from ``BIMWallProperties`` without touching IFC.
|
"""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"):
|
def set_origin(self, context, event, connection="ATSTART"):
|
||||||
obj = context.active_object
|
obj = context.active_object
|
||||||
element = tool.Ifc.get_entity(obj)
|
ref = tool.Wall.get_world_reference_line(obj)
|
||||||
layers = tool.Model.get_material_layer_parameters(element)
|
if ref is None:
|
||||||
axis = tool.Model.get_wall_axis(obj, layers)
|
return
|
||||||
start = Vector((axis["reference"][0][0], axis["reference"][0][1], obj.location.z))
|
start = Vector((ref[0].x, ref[0].y, obj.location.z))
|
||||||
end = Vector((axis["reference"][1][0], axis["reference"][1][1], obj.location.z))
|
end = Vector((ref[1].x, ref[1].y, obj.location.z))
|
||||||
direcion = end - start
|
direcion = end - start
|
||||||
value = end if connection == "ATSTART" else start
|
value = end if connection == "ATSTART" else start
|
||||||
self.input_ui.set_value("X", value[0])
|
self.input_ui.set_value("X", value[0])
|
||||||
@@ -1424,8 +1437,11 @@ class DumbWallJoiner:
|
|||||||
if tool.Ifc.is_moved(wall1):
|
if tool.Ifc.is_moved(wall1):
|
||||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1)
|
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1)
|
||||||
|
|
||||||
axis1 = tool.Model.get_wall_axis(wall1)
|
ref = tool.Wall.get_world_reference_line(wall1)
|
||||||
intersect, cut_percentage = mathutils.geometry.intersect_point_line(target.to_2d(), *axis1["reference"])
|
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)):
|
if cut_percentage < 0 or cut_percentage > 1 or tool.Cad.is_x(cut_percentage, (0, 1)):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1469,7 +1485,7 @@ class DumbWallJoiner:
|
|||||||
for opening in [
|
for opening in [
|
||||||
r.RelatedOpeningElement for r in element1.HasOpenings if not r.RelatedOpeningElement.HasFillings
|
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:
|
if min_t > cut_percentage:
|
||||||
# Opening lies entirely past the cut — only element2 should keep it.
|
# Opening lies entirely past the cut — only element2 should keep it.
|
||||||
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
||||||
@@ -1477,7 +1493,7 @@ class DumbWallJoiner:
|
|||||||
for opening in [
|
for opening in [
|
||||||
r.RelatedOpeningElement for r in element2.HasOpenings if not r.RelatedOpeningElement.HasFillings
|
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:
|
if max_t < cut_percentage:
|
||||||
# Opening lies entirely before the cut — only element1 should keep it.
|
# Opening lies entirely before the cut — only element1 should keep it.
|
||||||
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
||||||
@@ -1494,8 +1510,8 @@ class DumbWallJoiner:
|
|||||||
filling = rel.RelatedBuildingElement
|
filling = rel.RelatedBuildingElement
|
||||||
filling_obj = tool.Ifc.get_object(filling)
|
filling_obj = tool.Ifc.get_object(filling)
|
||||||
filling_location = filling_obj.matrix_world.translation
|
filling_location = filling_obj.matrix_world.translation
|
||||||
_, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis1["reference"])
|
_, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis_world_2d)
|
||||||
min_t, max_t = _opening_axis_extent(opening, axis1["reference"], unit_scale)
|
min_t, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale)
|
||||||
void_straddles = min_t < cut_percentage < max_t
|
void_straddles = min_t < cut_percentage < max_t
|
||||||
if filling_position > cut_percentage:
|
if filling_position > cut_percentage:
|
||||||
# The filling should be moved from element1 to element2.
|
# 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:
|
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||||
"""Wall-specific gizmos.
|
"""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
|
wall top (Z=height in wall-local). Clicking extends the wall's height to
|
||||||
the cursor's Z.
|
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
|
The baseline-state triplet (exterior/center/interior) and the rotate-90
|
||||||
icon live in ``feature_slots`` — the base class handles creation and
|
icon live in ``feature_slots`` — the base class handles creation and
|
||||||
edit-row positioning; this group only picks variant visibility per
|
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()
|
default_color, highlight_color = self.get_decoration_colors()
|
||||||
self.split_gizmo = self._setup_icon_gizmo(
|
self.split_gizmo = self._setup_icon_gizmo(
|
||||||
"VIEW3D_GT_split",
|
"VIEW3D_GT_split",
|
||||||
@@ -2104,7 +2128,6 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
|||||||
"bim.extend_wall_height_to_cursor",
|
"bim.extend_wall_height_to_cursor",
|
||||||
highlight_color,
|
highlight_color,
|
||||||
)
|
)
|
||||||
self.setup_pen_row_toggle_openings_icon()
|
|
||||||
if context.region is not None:
|
if context.region is not None:
|
||||||
type(self)._active_instances[context.region.as_pointer()] = weakref.ref(self)
|
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:
|
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
|
"""Pick which baseline variant is visible during edit.
|
||||||
the idle-row toggle-openings icon.
|
|
||||||
|
|
||||||
Baseline triplet: the base class's slot loop already wrote a billboard
|
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
|
matrix on each variant member at the same X (the cycle slot, since
|
||||||
wall has no ``cycle_type_operator``). This hook only flips ``hide``
|
wall has no ``cycle_type_operator``). This hook only flips ``hide``
|
||||||
on each member based on ``props.desired_offset_baseline`` so exactly
|
on each member based on ``props.desired_offset_baseline`` so exactly
|
||||||
one variant shows. The rotate-90 icon is a single-icon feature slot
|
one variant shows. The rotate-90 icon is a single-icon feature slot
|
||||||
and is fully handled by the base.
|
and is fully handled by the base. The toggle-openings idle icon is
|
||||||
|
declared in ``idle_slots`` and positioned 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."""
|
|
||||||
active_variant = self._BASELINE_TO_VARIANT.get(props.desired_offset_baseline)
|
active_variant = self._BASELINE_TO_VARIANT.get(props.desired_offset_baseline)
|
||||||
for variant in ("exterior", "center", "interior"):
|
for variant in ("exterior", "center", "interior"):
|
||||||
gz = getattr(self, f"baseline_{variant}_gizmo", None)
|
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)
|
gz.hide = self.is_gizmo_hidden_by_modal(gz)
|
||||||
else:
|
else:
|
||||||
gz.hide = True
|
gz.hide = True
|
||||||
self.update_pen_row_toggle_openings_icon(context, mw, props)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_wall_extend_flips(
|
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 "
|
"billboarded_at writes for the join/unjoin/extend/fillet icons bypass "
|
||||||
"the stacking contract and re-introduce the top-view collapse bug."
|
"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."
|
||||||
|
)
|
||||||
|
|||||||
@@ -588,6 +588,10 @@ class TestGenerateStair2DProfile(NewFile):
|
|||||||
|
|
||||||
|
|
||||||
class TestUsingArrays(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):
|
def setup_array(self, add_second_layer=False, sync_children=False):
|
||||||
tool.Project.get_project_props().template_file = "0"
|
tool.Project.get_project_props().template_file = "0"
|
||||||
bpy.ops.bim.create_project()
|
bpy.ops.bim.create_project()
|
||||||
@@ -619,9 +623,9 @@ class TestUsingArrays(NewFile):
|
|||||||
def test_remove_array_last_to_first(self):
|
def test_remove_array_last_to_first(self):
|
||||||
self.setup_array(add_second_layer=True)
|
self.setup_array(add_second_layer=True)
|
||||||
bpy.ops.bim.remove_array(item=1)
|
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)
|
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):
|
def test_remove_array_first_to_last(self):
|
||||||
self.setup_array(add_second_layer=True)
|
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 second layer
|
||||||
bpy.ops.bim.apply_array() # apply first layer
|
bpy.ops.bim.apply_array() # apply first layer
|
||||||
|
|
||||||
objs = bpy.context.selected_objects
|
objs = self._array_objects()
|
||||||
assert len(objs) == 12
|
assert len(objs) == 12
|
||||||
|
|
||||||
# check BBIM_Array psets are removed
|
# check BBIM_Array psets are removed
|
||||||
|
|||||||
Reference in New Issue
Block a user