Add IconSlot placeholders + stair xN tread label

Add a clickable "xN" badge to GizmoStairEdition's edit row, mirroring
the array's popup-input UX: click opens a number dialog (no more
shift+click-into-modal). Text-only — no 2x2 grid glyph.

Structural changes that enable this cleanly:

* IconSlot.placeholder=True: slots reserve an X position in the row
  without auto-creating a gizmo. Subclasses resolve the reserved X via
  _slot_x_positions()[name] to place their own dynamic gizmos. Drops
  the brittle "remember to add extra_gap_before" workaround that would
  silently rot on slot reorders.

* Array bug fix: the count badge collided with the "-" icon because
  the slot manager placed count_minus at the cycle position (X=0.87)
  where ICON_NUMBER_X also lives. Migrating the badge to a placeholder
  slot lets the manager allocate the X naturally and the "-" no longer
  overlaps. ICON_NUMBER_X constant removed.

* IntegerInputDialogMixin in parametric_lifecycle.py: extracts the
  popup-dialog plumbing shared between InputArrayCount and the new
  InputStairTreads. Subclasses declare an IntProperty + attr_name +
  props_getter; the mixin owns invoke/execute. _resolve_props helper
  factors the common obj/props/requires_editing prologue.

Tests: BIM_GT_count_label registration; IconSlot placeholder contract
(no gizmo_idname required; gizmo_attrs() returns empty); the stair
edit-row slot layout reserves the label position between tread_lock
and plus at one ICON_ARRAY_GAP each; visibility propagates from
props.is_editing.

Partly generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-02 08:38:01 +02:00
parent 1ba9341201
commit b154cadf3b
7 changed files with 253 additions and 51 deletions
@@ -154,6 +154,7 @@ classes = (
gizmos.GizmoArrayParent,
gizmos.GizmoArrayAll,
gizmos.GizmoArrayLayerIndicator,
gizmos.GizmoCountLabel,
gizmos.GizmoMerge,
gizmos.GizmoSplit,
gizmos.GizmoUnjoin,
+63 -7
View File
@@ -3871,6 +3871,44 @@ class GizmoArrayLayerIndicator(bpy.types.Gizmo):
draw_array_layer_children_bbox(context, parent_element, self._layer_index)
class GizmoCountLabel(bpy.types.Gizmo):
"""``xN`` text label rendered from 7-segment digit triangles.
Mirrors a caller-supplied integer (set via :meth:`set_count`) into a
live count badge. No icon glyph; the gizmo is the number alone."""
bl_idname = "BIM_GT_count_label"
__slots__ = ("custom_shape", "_count", "_built_count", "_outlined_batch")
def setup(self) -> None:
self._count = 0
self._built_count = -1
tris = _count_label_tris(self._count, 0.0, 0.0)
self.custom_shape = self.new_custom_shape("TRIS", tris)
self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris})
self._built_count = 0
def set_count(self, count: int) -> None:
self._count = int(count)
def _ensure_shape(self) -> None:
if self._built_count != self._count:
tris = _count_label_tris(self._count, 0.0, 0.0)
self.custom_shape = self.new_custom_shape("TRIS", tris)
self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris})
self._built_count = self._count
def draw(self, context: bpy.types.Context) -> None:
self._ensure_shape()
color = (*self.color_highlight, 1.0) if self.is_highlight else (*self.color, 1.0)
draw_tris_with_outline(self._outlined_batch, self.matrix_basis @ self.matrix_offset, color)
def draw_select(self, context: bpy.types.Context, select_id: int) -> None:
self._ensure_shape()
self.draw_custom_shape(self.custom_shape, select_id=select_id)
class GizmoMerge(StaticTrisGizmoMixin, bpy.types.Gizmo):
"""Two arrows pointing inward toward each other — conveys joining/merging elements."""
@@ -4986,11 +5024,16 @@ class IconSlot:
action (trash) from the routine edit controls.
- ``operator_props`` tuple of (key, value) pairs forwarded to
``target_set_operator``'s return value (e.g. ``increment=1`` for a
+/- adjuster, ``property_name="..."`` for a generic toggle)."""
+/- adjuster, ``property_name="..."`` for a generic toggle).
- ``placeholder`` when ``True``, the slot reserves an X position in
the row but no auto-managed gizmo is created. Subclasses look the X
up via ``_slot_x_positions()[name]`` to place their own dynamically-
built gizmos (e.g. a live count label). ``gizmo_idname`` / ``operator``
are unused for placeholders."""
name: str
gizmo_idname: str | tuple[str, ...]
operator: str
gizmo_idname: str | tuple[str, ...] = ""
operator: str = ""
# Matches DEFAULT_BILLBOARD_SCALE — the scale validate/cancel render at,
# so slots that don't override land at the same visual size by default.
# Helper icons (+/- count adjusters, lock pairs, delete) override with
@@ -5000,10 +5043,13 @@ class IconSlot:
variants: tuple[str, ...] = ()
extra_gap_before: float = 0.0
operator_props: tuple[tuple[str, Any], ...] = ()
placeholder: bool = False
def __post_init__(self) -> None:
# Validate shape at class-definition time so a typo doesn't surface
# as a runtime error in the gizmo group's setup() three layers deep.
if self.placeholder:
return
if self.variants:
if isinstance(self.gizmo_idname, str):
pass # prefix form — idname auto-suffixed per variant
@@ -5015,10 +5061,11 @@ class IconSlot:
f"to be either a string prefix (auto-suffixed as <prefix>_<variant>) or a "
f"tuple of {len(self.variants)} explicit idnames, got {self.gizmo_idname!r}"
)
elif not isinstance(self.gizmo_idname, str):
elif not isinstance(self.gizmo_idname, str) or not self.gizmo_idname:
raise TypeError(
f"IconSlot({self.name!r}): single-icon slot requires gizmo_idname str, "
f"got {self.gizmo_idname!r} (set variants=(...) if you want a multi-variant slot)"
f"got {self.gizmo_idname!r} (set variants=(...) if you want a multi-variant "
f"slot, or placeholder=True for a reserved-position slot)"
)
def variant_idnames(self) -> tuple[str, ...]:
@@ -5034,7 +5081,10 @@ class IconSlot:
def gizmo_attrs(self) -> tuple[str, ...]:
"""Names of every ``self.*`` attribute this slot writes during setup.
Returns one for a single slot, N for an N-variant slot."""
Returns one for a single slot, N for an N-variant slot, and an empty
tuple for placeholder slots (which reserve X without an auto-gizmo)."""
if self.placeholder:
return ()
if self.variants:
return tuple(f"{self.name}_{variant}_gizmo" for variant in self.variants)
return (f"{self.name}_gizmo",)
@@ -5879,8 +5929,12 @@ class BaseParametricGizmoGroup:
# Feature-specific edit-row icons. Subclasses declare them via
# ``feature_slots``; multi-variant slots create one gizmo per
# variant at the same X (e.g. a lock pair, a baseline triplet) and
# the subclass picks which is visible per frame.
# the subclass picks which is visible per frame. Placeholder slots
# only reserve an X position — the subclass creates its own gizmo
# there in ``setup_element_specific_gizmos``.
for slot in self.feature_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()):
@@ -6155,6 +6209,8 @@ class BaseParametricGizmoGroup:
# whenever the gizmo group polls visible.
slot_positions = self._slot_x_positions()
for slot in self.feature_slots:
if slot.placeholder:
continue
slot_x = self.ICON_VALIDATE_X + slot_positions[slot.name]
attrs = slot.gizmo_attrs()
if slot.variants:
@@ -210,6 +210,7 @@ classes = (
stair.ToggleStairProperty,
stair.AdjustStairTreads,
stair.SetStairTreads,
stair.InputStairTreads,
stair.CycleStairType,
stair.GizmoStairEdition,
sverchok_modifier.CreateNewSverchokGraph,
+24 -40
View File
@@ -34,7 +34,10 @@ from bonsai.bim.module.drawing.gizmos import (
DimensionGizmoConfig,
IconSlot,
)
from bonsai.bim.parametric_lifecycle import ParametricEditMixinBase
from bonsai.bim.parametric_lifecycle import (
IntegerInputDialogMixin,
ParametricEditMixinBase,
)
def _wipe_array_children(layers: list) -> None:
@@ -1052,11 +1055,10 @@ class RemoveArrayLayerFromEdit(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class InputArrayCount(bpy.types.Operator):
"""Open a number-input dialog so the user can type a new ``count`` during
an active edit lifecycle. Bound to the world-space count gizmo in the edit
row (between cancel and minus) — for users who'd rather type a value than
repeatedly click +/-."""
class InputArrayCount(IntegerInputDialogMixin, bpy.types.Operator):
"""Popup-dialog entry point for typing a new draft ``count`` during an
active array edit lifecycle. Bound to the world-space count gizmo in
the edit row."""
bl_idname = "bim.input_array_count"
bl_label = "Set Array Count"
@@ -1064,26 +1066,9 @@ class InputArrayCount(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
count: bpy.props.IntProperty(name="Count", default=1, min=1)
def invoke(self, context, event):
obj = context.active_object
if not obj:
return {"CANCELLED"}
props = tool.Model.get_array_props(obj)
if not props.is_editing:
return {"CANCELLED"}
self.count = max(1, props.count)
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
obj = context.active_object
if not obj:
return {"CANCELLED"}
props = tool.Model.get_array_props(obj)
if not props.is_editing:
return {"CANCELLED"}
props.count = max(1, self.count)
return {"FINISHED"}
attr_name = "count"
props_getter = staticmethod(tool.Model.get_array_props)
requires_editing = True
class AdjustArrayCount(bpy.types.Operator):
@@ -1138,18 +1123,18 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
hide_pen_button = True
# Editing row layout: validate | cancel | xN | - | + | method | trash.
# The count-label (xN) gizmo sits at the cycle slot (X = 0.87), positioned
# manually in ``_refresh_element_specific`` because it's a label that
# replaces the cycle icon rather than a row slot. The +/-/method/trash
# icons live in ``feature_slots`` below — the base class assigns X
# positions from tuple order; the trash carries ``extra_gap_before`` to
# visually separate the destructive action from the routine controls.
ICON_NUMBER_X = 0.87
# The ``count_label`` placeholder reserves the position for the
# dynamically-built ``xN`` gizmo; the +/-/method/trash icons live in
# ``feature_slots`` below and the base class assigns X positions from
# tuple order. The trash carries ``extra_gap_before`` to visually
# separate the destructive action from the routine controls.
# Render scale for the +/- and method-toggle icons — ~70% of the standard
# 0.5 used for validate/cancel. Makes the helpers look secondary.
ICON_HELPER_SCALE = 0.35
feature_slots: ClassVar[tuple[IconSlot, ...]] = (
IconSlot(name="count_label", placeholder=True),
IconSlot(
name="count_minus",
gizmo_idname="VIEW3D_GT_minus",
@@ -1290,12 +1275,11 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
``feature_slots`` and are auto-created by the base class."""
default_color, highlight_color = self.get_decoration_colors()
# World-space count display for the edit row. Click opens a numeric
# input dialog (``bim.input_array_count``) so the user can type a
# value directly instead of clicking +/- repeatedly. Renders the same
# ``xN`` glyph as the idle-state per-layer icons for visual consistency.
# Sits at the cycle slot — it's a label that replaces the cycle icon,
# not a row slot, so it's positioned manually below rather than via
# ``feature_slots``.
# input dialog so the user can type a value directly instead of
# clicking +/- repeatedly. Renders the same ``xN`` glyph as the
# idle-state per-layer icons for visual consistency. Position is
# reserved by the ``count_label`` placeholder slot in
# ``feature_slots``; the matrix is set per-frame below.
self.count_label_gizmo = self.gizmos.new("BIM_GT_array_layer_indicator")
self.count_label_gizmo.use_draw_scale = False
self.count_label_gizmo.color = default_color
@@ -1431,7 +1415,7 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.count_label_gizmo.set_count(int(props.count))
world_pos = mw @ Vector(
(
self.ICON_VALIDATE_X + self.ICON_NUMBER_X,
self.ICON_VALIDATE_X + self._slot_x_positions()["count_label"],
icon_y,
icon_z,
)
+47 -4
View File
@@ -37,6 +37,7 @@ from bonsai.bim.module.drawing.gizmos import (
DimensionGizmoConfig,
IconSlot,
)
from bonsai.bim.parametric_lifecycle import IntegerInputDialogMixin
from bonsai.tool.numeric_input import (
IntegerInputState,
run_integer_input_modal,
@@ -383,6 +384,20 @@ class AdjustStairTreads(bpy.types.Operator):
return {"FINISHED"}
class InputStairTreads(IntegerInputDialogMixin, bpy.types.Operator):
"""Popup-dialog entry point for typing a new ``number_of_treads`` value.
Bound to the world-space ``xN`` count label in the stair edit row."""
bl_idname = "bim.input_stair_treads"
bl_label = "Set Number of Treads"
bl_description = "Type the number of treads for this stair"
bl_options = {"REGISTER", "UNDO"}
number_of_treads: IntProperty(name="Number of Treads", default=1, min=1)
attr_name = "number_of_treads"
props_getter = staticmethod(tool.Model.get_stair_props)
class SetStairTreads(bpy.types.Operator):
"""Set the number of treads to a specific value."""
@@ -468,11 +483,12 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
bl_options = {"3D", "PERSISTENT"}
# === Stair-Specific Icon Layout ===
# Row order: [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus]
# Row order: [Validate] [Cancel] [Cycle] [TreadLock] [xN] [Plus] [Minus]
# The base class assigns X positions from ``feature_slots`` tuple order —
# adding an icon is a one-line append, no hardcoded X constant.
ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger)
ICON_CYCLE_SCALE = 0.3 # Scale for cycle type icon
ICON_COUNT_LABEL_SCALE = 0.36 # Scale for the xN tread-count label
ICON_Z_OFFSET = 0.5 # Z offset above geometry for editing icons
feature_slots: ClassVar[tuple[IconSlot, ...]] = (
@@ -484,6 +500,7 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
color=(1.0, 1.0, 1.0),
operator_props=(("property_name", "custom_tread_lock"),),
),
IconSlot(name="tread_count_label", placeholder=True),
IconSlot(
name="plus",
gizmo_idname="VIEW3D_GT_plus",
@@ -618,16 +635,28 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
return tool.Blender.Modifier.is_stair(element)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Create the total-length lock as an open/closed pair. Click toggles
"""Create the total-length lock as an open/closed pair plus the
``xN`` tread-count label. Lock click toggles
``props.total_length_lock``; the per-frame update hook picks which
member is visible. Anchored to the stair's far X end (not the edit
row) so it's positioned by ``_update_lock_gizmo_position`` rather
than the toolbar slot system."""
than the toolbar slot system.
The count label binds to ``bim.input_stair_treads`` (popup dialog)
for click-to-type input and sits at the X reserved by the
``tread_count_label`` placeholder slot in ``feature_slots``."""
self.total_length_lock_open_gizmo, self.total_length_lock_closed_gizmo = self.create_icon_gizmo_lock_pair(
"bim.toggle_stair_property",
self.COLOR_BLUE,
property_name="total_length_lock",
)
default_color, highlight_color = self.get_decoration_colors()
self.tread_count_label_gizmo = self.gizmos.new("BIM_GT_count_label")
self.tread_count_label_gizmo.use_draw_scale = False
self.tread_count_label_gizmo.color = default_color
self.tread_count_label_gizmo.color_highlight = highlight_color
self.tread_count_label_gizmo.alpha = 0.8
self.tread_count_label_gizmo.target_set_operator("bim.input_stair_treads")
def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
@@ -667,12 +696,15 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.tread_lock_closed_gizmo.hide = not props.custom_tread_lock
def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None:
"""Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions."""
"""Update visibility of the +/- tread count gizmos and the ``xN``
label. Positioning is handled in ``_update_editing_icon_positions``."""
if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"):
return
self.update_gizmo_visibility(self.plus_gizmo, props.is_editing)
# Minus has additional condition: number_of_treads > 1
self.update_gizmo_visibility(self.minus_gizmo, props.is_editing and props.number_of_treads > 1)
if hasattr(self, "tread_count_label_gizmo"):
self.update_gizmo_visibility(self.tread_count_label_gizmo, props.is_editing)
def _update_dimension_gizmo_positions(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
@@ -800,3 +832,14 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.set_icon_gizmo_position(
"minus_gizmo", mw, slot_x["minus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
)
if hasattr(self, "tread_count_label_gizmo"):
self.tread_count_label_gizmo.set_count(int(props.number_of_treads))
self.set_icon_gizmo_position(
"tread_count_label_gizmo",
mw,
slot_x["tread_count_label"],
y_pos,
icon_z,
billboard_rot,
scale=self.ICON_COUNT_LABEL_SCALE,
)
@@ -528,6 +528,51 @@ class PickTypeMixin(TypeAccessorBase):
return {"FINISHED"}
class IntegerInputDialogMixin:
"""Operator mixin that mirrors a per-feature ``IntProperty`` on the
operator into a draft attribute on the active object's parametric props,
via Blender's ``invoke_props_dialog`` popup.
Subclasses declare:
- ``attr_name`` name of the IntProperty on the subclass AND of the
attribute on the resolved props (same name on both sides).
- ``props_getter`` ``staticmethod(tool.Model.get_<feature>_props)``.
- ``requires_editing`` True iff the operator must no-op outside an
active edit lifecycle. Default False.
- ``value_min`` minimum value to clamp to. Default 1."""
attr_name: ClassVar[str] = ""
props_getter: ClassVar[Callable[[bpy.types.Object], bpy.types.PropertyGroup]]
requires_editing: ClassVar[bool] = False
value_min: ClassVar[int] = 1
def _resolve_props(self, context: bpy.types.Context) -> bpy.types.PropertyGroup | None:
"""Return the active object's parametric props if the operator is
allowed to fire, ``None`` otherwise (caller bails with ``CANCELLED``)."""
obj = context.active_object
if not obj:
return None
props = self.props_getter(obj)
if self.requires_editing and not props.is_editing:
return None
return props
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: # noqa: ARG002
props = self._resolve_props(context)
if props is None:
return {"CANCELLED"}
setattr(self, self.attr_name, max(self.value_min, getattr(props, self.attr_name)))
return context.window_manager.invoke_props_dialog(self)
def execute(self, context: bpy.types.Context) -> set[str]:
props = self._resolve_props(context)
if props is None:
return {"CANCELLED"}
setattr(props, self.attr_name, max(self.value_min, getattr(self, self.attr_name)))
return {"FINISHED"}
# --- Undo-resync registry ----------------------------------------------------
#
# Per-type regenerators called from ``resync_parametric_drafts_after_undo``
@@ -133,3 +133,75 @@ def test_set_icon_gizmo_position_does_not_apply_object_rotation():
for row_a, row_b in zip(stub.matrix_basis, expected):
for va, vb in zip(row_a, row_b):
assert abs(va - vb) < 1e-6
def test_icon_slot_placeholder_skips_validation_and_returns_no_attrs():
"""Placeholder slots reserve an X position without an auto-created gizmo:
construction must not require ``gizmo_idname`` / ``operator``, and
``gizmo_attrs()`` must return an empty tuple so the base class's
setup/positioning loops naturally skip the slot."""
from bonsai.bim.module.drawing.gizmos import IconSlot
slot = IconSlot(name="my_label", placeholder=True)
assert slot.placeholder is True
assert slot.gizmo_attrs() == ()
with pytest.raises(TypeError, match="gizmo_idname"):
IconSlot(name="broken")
def test_count_label_gizmo_is_registered():
"""The shared text-only ``xN`` gizmo must register so the stair group's
``gizmos.new("BIM_GT_count_label")`` resolves."""
from bonsai.bim.module.drawing.gizmos import GizmoCountLabel
assert GizmoCountLabel.bl_idname == "BIM_GT_count_label"
assert bpy.types.Gizmo.bl_rna_get_subclass_py("BIM_GT_count_label") is GizmoCountLabel
def test_stair_edit_row_reserves_label_slot_between_tread_lock_and_plus():
"""The ``tread_count_label`` placeholder slot must sit one
``ICON_ARRAY_GAP`` past the tread-lock and one gap before the plus
icon, so the layout naturally allocates the count label's X without
any subclass-side gap math."""
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
from bonsai.bim.module.model.stair import GizmoStairEdition
slot_x = GizmoStairEdition._slot_x_positions()
gap = BaseParametricGizmoGroup.ICON_ARRAY_GAP
assert "tread_count_label" in slot_x
assert slot_x["tread_count_label"] - slot_x["tread_lock"] == pytest.approx(gap)
assert slot_x["plus"] - slot_x["tread_count_label"] == pytest.approx(gap)
assert slot_x["minus"] - slot_x["plus"] == pytest.approx(gap)
def test_update_tread_count_gizmos_toggles_label_with_editing():
"""``update_tread_count_gizmos`` must propagate ``props.is_editing``
to the label's hide state so the badge appears only inside edit mode."""
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
from bonsai.bim.module.model.stair import GizmoStairEdition
class _GizmoStub:
def __init__(self):
self.hide = False
plus_gz = _GizmoStub()
minus_gz = _GizmoStub()
label_gz = _GizmoStub()
fake_self = types.SimpleNamespace(
plus_gizmo=plus_gz,
minus_gizmo=minus_gz,
tread_count_label_gizmo=label_gz,
update_gizmo_visibility=lambda g, v: BaseParametricGizmoGroup.update_gizmo_visibility(fake_self, g, v),
is_gizmo_hidden_by_modal=lambda g: False,
)
props_editing = types.SimpleNamespace(is_editing=True, number_of_treads=5)
GizmoStairEdition.update_tread_count_gizmos(fake_self, props_editing)
assert label_gz.hide is False
props_idle = types.SimpleNamespace(is_editing=False, number_of_treads=5)
GizmoStairEdition.update_tread_count_gizmos(fake_self, props_idle)
assert label_gz.hide is True