Replace hardcoded icon-X constants with IconSlot layout manager

The parametric edit toolbar row used to assign each feature icon its
own ICON_<NAME>_X constant, with a separate FEATURE_ICON_MAX_X override
each subclass had to bump whenever a new icon was added. Forgetting the
bump silently collided icons — wall's rotate icon and the array button
both landed at X=1.24 in edit mode.

The new IconSlot dataclass + feature_slots tuple replace the
constants-and-override pattern with order-driven positioning: the
layout manager assigns each slot an X from its tuple index plus a
uniform ICON_ARRAY_GAP. Adding an icon is now a one-line append; the
"forget to bump" failure mode is structurally impossible.

Slot capabilities cover every existing icon-row shape:
* Single icon (wall rotate, array delete).
* N-variant slots — N gizmos at the same X with one visible per frame
  via a subclass picker (stair tread-lock open/closed, wall baseline
  exterior/center/interior). Pair becomes the N=2 case; triplet the
  N=3 case. Variant idnames can be authored either as a tuple of
  explicit names or as a string prefix that auto-suffixes _<variant>.
* Visibility prefs gate slot rendering without reflowing the row —
  hidden slots still consume their X position.
* Extra per-slot gap before for visual separation (array's delete
  trails the routine controls by an extra 0.2 m).
* Operator props forwarded to target_set_operator so adjusters
  (+/-, increment) and generic toggles (property_name=...) work.

When the cycle slot is unused, feature slots collapse into the cycle
position so the row stays tight — that's how wall's baseline triplet
sits at X=0.87 without a gap before it.

Three subclasses migrate to the new system:
* wall.py — rotate icon + baseline triplet variants. Drops
  ICON_ROTATE_X, _BASELINE_GIZMO_ATTRS, the manual triplet creation
  loop, and the matching positioning block in _update_icon_row_extras
  (it now just picks variant visibility).
* stair.py — tread_lock pair (open/closed) + plus + minus.
  _update_editing_icon_positions reads slot X via _slot_x_positions
  instead of three hardcoded constants. Also fixes the standalone
  total_length_lock gizmo, which was broken since PR4 split
  VIEW3D_GT_lock into open/closed pair (caller wasn't updated).
* array.py — count_minus + count_plus + method + delete (with
  extra_gap_before=0.20 to separate the destructive action).
  Drops the manual edit-row positioning loop entirely; the base
  loop handles it. GizmoArrayChild now inherits BillboardingGizmoGroupMixin
  and uses the shared setup_icon_gizmo helper, dropping its
  duplicated _make_icon wrapper.

Two helpers added on BillboardingGizmoGroupMixin to fold the duplicated
prefs/color preamble that appeared at the top of six wall gizmo setups
plus the array-child setup:
* get_decoration_colors() — (decorations_colour, decorator_color_selected),
  the active-state pair.
* get_unselected_decoration_colors() — (decorator_color_unselected,
  decorator_color_selected) for gizmos surfaced on already-selected
  geometry that should not pull focus.

Verified: headless smoke green at 1267 BIM_OT_ classes,
test_parametric_registry.py 8/8 pass, wall lane 29/29 pass,
model lane unchanged at 135 pass + 7 pre-existing v0.8.0 failures
(no regressions). ruff + black clean.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-01 16:19:42 +02:00
parent 44723e83b6
commit 4cf34b69d2
4 changed files with 427 additions and 264 deletions
+205 -16
View File
@@ -4924,12 +4924,116 @@ class BillboardingGizmoGroupMixin:
"""Convenience wrapper over `setup_icon_gizmo` for subclasses.""" """Convenience wrapper over `setup_icon_gizmo` for subclasses."""
return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha) return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha)
def get_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Standard (default, highlight) color pair for active-state gizmos.
Pulls from the addon preferences same source consumed by every
Bonsai decorator. Hover-class gizmos that should not pull focus
should use ``get_unselected_decoration_colors`` instead."""
prefs = tool.Blender.get_addon_preferences()
return prefs.decorations_colour[:3], prefs.decorator_color_selected[:3]
def get_unselected_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Lower-priority (unselected default, highlight) pair for gizmos
that surface on already-selected geometry and shouldn't compete
visually with the selection outline (e.g. array-child navigation)."""
prefs = tool.Blender.get_addon_preferences()
return prefs.decorator_color_unselected[:3], prefs.decorator_color_selected[:3]
def position_gizmos(self, context: bpy.types.Context) -> None: def position_gizmos(self, context: bpy.types.Context) -> None:
raise NotImplementedError( raise NotImplementedError(
f"{type(self).__name__} must implement position_gizmos(context) when using BillboardingGizmoGroupMixin." f"{type(self).__name__} must implement position_gizmos(context) when using BillboardingGizmoGroupMixin."
) )
@dataclass(frozen=True)
class IconSlot:
"""One slot in a parametric edit gizmo's icon toolbar row.
The slot's X coordinate is COMPUTED from its index in ``feature_slots`` —
never set explicitly. Adding an icon is a one-line append; the layout
manager resolves the X. Hidden slots STILL CONSUME their X position so
toggling a visibility preference doesn't shift the row.
Fields:
- ``gizmo_idname`` for single-icon slots, the full Blender gizmo
idname. For multi-variant slots, EITHER a string PREFIX that auto-
suffixes ``_<variant>`` per member (the common case e.g.
``"VIEW3D_GT_lock"`` + variants ``("open", "closed")`` becomes
``VIEW3D_GT_lock_open`` / ``VIEW3D_GT_lock_closed``) OR a tuple of
explicit idnames matching the variant count when the variants
don't share a prefix. Attributes created on the gizmo group are
``self.<name>_gizmo`` for single slots, ``self.<name>_<variant>_gizmo``
for each variant in multi-variant slots.
- ``variants`` variant suffixes, e.g. ``("open", "closed")`` for a
lock pair, ``("exterior", "center", "interior")`` for a baseline
cycle. Empty tuple = single icon.
- ``color`` RGB tuple. ``None`` falls back to the gizmo group's default
decoration color. Use the group's ``COLOR_RED`` / ``COLOR_GREEN`` /
``COLOR_BLUE`` literals for state-coded icons.
- ``visibility_pref`` attribute name read off ``get_gizmo_prefs()``.
Slot is hidden when that pref is falsy. ``None`` = always visible
during edit.
- ``extra_gap_before`` extra spacing past the default uniform gap, in
meters. Use sparingly e.g. to visually separate a destructive
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)."""
name: 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
# smaller values (0.20 - 0.35) to signal secondary affordance.
scale: float = DEFAULT_BILLBOARD_SCALE
color: tuple[float, float, float] | None = None
variants: tuple[str, ...] = ()
visibility_pref: str | None = None
extra_gap_before: float = 0.0
operator_props: tuple[tuple[str, Any], ...] = ()
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.variants:
if isinstance(self.gizmo_idname, str):
pass # prefix form — idname auto-suffixed per variant
elif isinstance(self.gizmo_idname, tuple) and len(self.gizmo_idname) == len(self.variants):
pass # explicit-tuple form
else:
raise TypeError(
f"IconSlot({self.name!r}): variants={self.variants} requires gizmo_idname "
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):
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)"
)
def variant_idnames(self) -> tuple[str, ...]:
"""Resolve per-variant gizmo idnames. For prefix form, suffix each
variant onto the prefix; for tuple form, return as is. Single-icon
slots return a one-element tuple containing the idname."""
if not self.variants:
assert isinstance(self.gizmo_idname, str)
return (self.gizmo_idname,)
if isinstance(self.gizmo_idname, str):
return tuple(f"{self.gizmo_idname}_{variant}" for variant in self.variants)
return self.gizmo_idname
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."""
if self.variants:
return tuple(f"{self.name}_{variant}_gizmo" for variant in self.variants)
return (f"{self.name}_gizmo",)
class BaseParametricGizmoGroup: class BaseParametricGizmoGroup:
"""Base mixin for parametric element gizmo groups (doors, windows, stairs, etc.). """Base mixin for parametric element gizmo groups (doors, windows, stairs, etc.).
@@ -5028,17 +5132,14 @@ class BaseParametricGizmoGroup:
ICON_VALIDATE_X = 0.0 # X position of validate (checkmark) icon ICON_VALIDATE_X = 0.0 # X position of validate (checkmark) icon
ICON_CANCEL_X = 0.5 # X offset from validate for cancel (X) icon ICON_CANCEL_X = 0.5 # X offset from validate for cancel (X) icon
ICON_CYCLE_X = 0.87 # X offset from validate for cycle (arrow) icon ICON_CYCLE_X = 0.87 # X offset from validate for cycle (arrow) icon
# Rightmost local-X used by feature-specific icons (across both idle and # Subclasses append to declare feature icons in the edit-mode toolbar row.
# edit states). Subclasses override when they add icons past the cycle # The layout manager assigns each slot an X position from its tuple
# slot at 0.87 — currently wall (rotate at 1.24) and stair (minus at # index — adding a new icon is a one-line append, no hardcoded X
# 1.98). Drives both the ARRAY button position (this class) AND the # constant, no "remember to bump the right edge" rule. The trailing
# array-layer-icons start position (``GizmoArrayEdition`` runtime lookup), # ARRAY button is positioned past the last slot automatically.
# so non-colliding features get a tight layout while wall / stair shift feature_slots: ClassVar[tuple[IconSlot, ...]] = ()
# the array-related slots outward to avoid stomping on the rotate / # Gap between adjacent slots past the leading validate/cancel/cycle
# tread-lock / +/- icons. # triplet, AND between the last slot and the ARRAY button.
FEATURE_ICON_MAX_X: float = 0.87
# Gap between the last feature icon and the ARRAY button (or the first
# array layer icon in idle state).
ICON_ARRAY_GAP: float = 0.37 ICON_ARRAY_GAP: float = 0.37
ICON_Z_OFFSET = 0.5 # Height above element for icons ICON_Z_OFFSET = 0.5 # Height above element for icons
ICON_Y_OFFSET = GIZMO_OFFSET * 2 # Y offset to keep icons clear of geometry ICON_Y_OFFSET = GIZMO_OFFSET * 2 # Y offset to keep icons clear of geometry
@@ -5060,6 +5161,37 @@ class BaseParametricGizmoGroup:
super().__init_subclass__(**kwargs) super().__init_subclass__(**kwargs)
BaseParametricGizmoGroup.REGISTRY.append(cls) BaseParametricGizmoGroup.REGISTRY.append(cls)
@classmethod
def _slot_x_positions(cls) -> dict[str, float]:
"""Map each ``feature_slot`` name to its X coordinate in the row.
Slots are laid out from the cycle position onward at uniform
``ICON_ARRAY_GAP`` spacing, plus any per-slot ``extra_gap_before``.
When the cycle slot is unused (no ``cycle_type_operator`` /
``pick_type_operator``), the first feature slot collapses into the
cycle position so the row stays tight that's how wall's baseline
triplet ends up at X=0.87 without a gap before it. Tuple order is
the only thing that controls X; rearranging the tuple rearranges
the row."""
positions: dict[str, float] = {}
has_cycle = bool(cls.cycle_type_operator) or bool(cls.pick_type_operator)
next_x = (cls.ICON_CYCLE_X + cls.ICON_ARRAY_GAP) if has_cycle else cls.ICON_CYCLE_X
for slot in cls.feature_slots:
next_x += slot.extra_gap_before
positions[slot.name] = next_x
next_x += cls.ICON_ARRAY_GAP
return positions
@classmethod
def _feature_row_right_edge(cls) -> float:
"""Right edge of the feature icon row, fed to the trailing ARRAY
button's X. Computed strictly from slot order + gaps; empty
``feature_slots`` collapses to the cycle position."""
positions = cls._slot_x_positions()
if not positions:
return cls.ICON_CYCLE_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
@@ -5762,6 +5894,17 @@ class BaseParametricGizmoGroup:
"VIEW3D_GT_menu", default_color, self.pick_type_operator, highlight_color "VIEW3D_GT_menu", default_color, self.pick_type_operator, highlight_color
) )
# 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.
for slot in self.feature_slots:
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
@@ -6024,10 +6167,51 @@ class BaseParametricGizmoGroup:
billboard_rot=billboard_rot, billboard_rot=billboard_rot,
scale=0.30, scale=0.30,
) )
# ARRAY button sits past the last feature-specific icon. Each # Feature slots: per-class IconSlot tuples driven by tuple order.
# gizmo group declares its own ``FEATURE_ICON_MAX_X`` (default # Hidden slots STILL CONSUME their X position — toggling a pref
# 0.87 past the cycle slot; wall / stair override it) so the # mustn't reflow the row (otherwise the array button drifts left
# ARRAY button never lands on top of a rotate / tread-lock icon. # whenever a user disables the rotate icon).
slot_positions = self._slot_x_positions()
# Only fetch prefs if some slot has a visibility_pref — groups
# without a prefs entry (e.g. array) would raise on the lookup.
needs_prefs = any(slot.visibility_pref for slot in self.feature_slots)
gizmo_prefs = self.get_gizmo_prefs() if needs_prefs else None
for slot in self.feature_slots:
slot_x = self.ICON_VALIDATE_X + slot_positions[slot.name]
attrs = slot.gizmo_attrs()
if slot.visibility_pref and not getattr(gizmo_prefs, slot.visibility_pref, True):
for attr in attrs:
gz = getattr(self, attr, None)
if gz is not None:
gz.hide = True
continue
if slot.variants:
# Multi-variant slot: write matrix on every variant member
# at the same anchor so a state flip never reveals a stale
# pose. The subclass's per-frame hook picks which member
# is visible — this loop doesn't toggle hide flags.
world_pos = mw @ Vector((slot_x, icon_y, icon_z))
matrix = billboarded_at(world_pos, billboard_rot, scale=slot.scale)
for attr in attrs:
gz = getattr(self, attr, None)
if gz is not None:
gz.matrix_basis = matrix
continue
gz = getattr(self, attrs[0], None)
if gz is None:
continue
gz.hide = self.is_gizmo_hidden_by_modal(gz)
self.set_icon_gizmo_position(
attrs[0],
mw=mw,
x=slot_x,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
scale=slot.scale,
)
# ARRAY button sits past the last feature-specific icon. Slot-based
# subclasses derive the right edge from the slot count.
if hasattr(self, "array_gizmo"): if hasattr(self, "array_gizmo"):
self.array_gizmo.hide = self.is_gizmo_hidden_by_modal(self.array_gizmo) self.array_gizmo.hide = self.is_gizmo_hidden_by_modal(self.array_gizmo)
# 30% smaller than the editing-icon-row default (0.50 → 0.35): # 30% smaller than the editing-icon-row default (0.50 → 0.35):
@@ -6037,7 +6221,7 @@ class BaseParametricGizmoGroup:
self.set_icon_gizmo_position( self.set_icon_gizmo_position(
"array_gizmo", "array_gizmo",
mw=mw, mw=mw,
x=self.ICON_VALIDATE_X + self.FEATURE_ICON_MAX_X + self.ICON_ARRAY_GAP, x=self.ICON_VALIDATE_X + self._feature_row_right_edge() + self.ICON_ARRAY_GAP,
y=icon_y, y=icon_y,
z=icon_z, z=icon_z,
billboard_rot=billboard_rot, billboard_rot=billboard_rot,
@@ -6061,6 +6245,11 @@ class BaseParametricGizmoGroup:
self.cancel_gizmo.hide = True self.cancel_gizmo.hide = True
if self.cycle_type_operator or self.pick_type_operator: if self.cycle_type_operator or self.pick_type_operator:
self.cycle_gizmo.hide = True self.cycle_gizmo.hide = True
for slot in self.feature_slots:
for attr in slot.gizmo_attrs():
gz = getattr(self, attr, None)
if gz is not None:
gz.hide = True
if hasattr(self, "array_gizmo"): if hasattr(self, "array_gizmo"):
self.array_gizmo.hide = True self.array_gizmo.hide = True
+68 -103
View File
@@ -28,7 +28,7 @@ from mathutils import Matrix, Vector
import bonsai.bim.module.drawing.gizmos as gizmo import bonsai.bim.module.drawing.gizmos as gizmo
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot
from bonsai.bim.parametric_lifecycle import ParametricEditMixinBase from bonsai.bim.parametric_lifecycle import ParametricEditMixinBase
@@ -1132,22 +1132,53 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
# collapses to a single per-feature pen. # collapses to a single per-feature pen.
hide_pen_button = True hide_pen_button = True
# Local-X positions of the editing-row extras (count label + adjuster icons). # Editing row layout: validate | cancel | xN | - | + | method | trash.
# Layout left-to-right at the same Y/Z as validate (ICON_VALIDATE_X = 0.0) and # The count-label (xN) gizmo sits at the cycle slot (X = 0.87), positioned
# cancel (ICON_VALIDATE_X + ICON_CANCEL_X = 0.5): # manually in ``_refresh_element_specific`` because it's a label that
# validate | cancel | xN | - | + | method-toggle | trash. # replaces the cycle icon rather than a row slot. The +/-/method/trash
# Spacing mirrors stair's editing-row constants so the icons match in visual rhythm. # icons live in ``feature_slots`` below — the base class assigns X
# Trash sits past the method toggle with a slightly wider gap so the destructive # positions from tuple order; the trash carries ``extra_gap_before`` to
# action stays visually separated from the routine edit controls. # visually separate the destructive action from the routine controls.
ICON_NUMBER_X = 0.87 ICON_NUMBER_X = 0.87
ICON_MINUS_X = 1.24
ICON_PLUS_X = 1.61
ICON_METHOD_X = 1.98
ICON_DELETE_X = 2.55
# Render scale for the +/- and method-toggle icons — ~70% of the standard # Render scale for the +/- and method-toggle icons — ~70% of the standard
# 0.5 used for validate/cancel. Makes the helpers look secondary. # 0.5 used for validate/cancel. Makes the helpers look secondary.
ICON_HELPER_SCALE = 0.35 ICON_HELPER_SCALE = 0.35
feature_slots: ClassVar[tuple[IconSlot, ...]] = (
IconSlot(
name="count_minus",
gizmo_idname="VIEW3D_GT_minus",
operator="bim.adjust_array_count",
scale=ICON_HELPER_SCALE,
color=(1.0, 0.2, 0.2),
operator_props=(("increment", -1),),
),
IconSlot(
name="count_plus",
gizmo_idname="VIEW3D_GT_plus",
operator="bim.adjust_array_count",
scale=ICON_HELPER_SCALE,
color=(0.1, 0.8, 0.1),
operator_props=(("increment", 1),),
),
IconSlot(
name="method",
gizmo_idname="VIEW3D_GT_cycle",
operator="bim.toggle_array_method",
scale=ICON_HELPER_SCALE,
),
IconSlot(
name="delete",
gizmo_idname="VIEW3D_GT_trash",
operator="bim.remove_array_layer_from_edit",
scale=ICON_HELPER_SCALE,
color=(1.0, 0.2, 0.2),
# Extra gap so the destructive action stays visually separated
# from the routine edit controls — matches the prior 0.57 gap.
extra_gap_before=0.20,
),
)
# Per-layer ARRAY icons — one shown in idle state per existing array # Per-layer ARRAY icons — one shown in idle state per existing array
# layer, surfaced to the right of the pen. Pre-allocated at setup time # layer, surfaced to the right of the pen. Pre-allocated at setup time
# (Blender's gizmo API doesn't support creating gizmos on demand at draw # (Blender's gizmo API doesn't support creating gizmos on demand at draw
@@ -1249,36 +1280,23 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
return tool.Parametric.is_array(element) return tool.Parametric.is_array(element)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Create the +/- count adjusters, the method toggle, and the """Create the world-space count label and per-layer ARRAY entry icons.
per-layer ARRAY entry icons (one per existing array layer).""" The +/- count adjusters, method toggle, and delete button live in
self.count_plus_gizmo = self.create_icon_gizmo( ``feature_slots`` and are auto-created by the base class."""
"VIEW3D_GT_plus", self.COLOR_GREEN, "bim.adjust_array_count", increment=1
)
self.count_minus_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_array_count", increment=-1
)
# Method toggle uses the cycle icon (circular arrow) — same affordance
# the stair / roof type-cycle gizmos use, signalling "click to swap".
default_color, highlight_color = self.get_decoration_colors() default_color, highlight_color = self.get_decoration_colors()
self.method_gizmo = self.create_icon_gizmo("VIEW3D_GT_cycle", default_color, "bim.toggle_array_method")
# World-space count display for the edit row. Click opens a numeric # World-space count display for the edit row. Click opens a numeric
# input dialog (``bim.input_array_count``) so the user can type a # input dialog (``bim.input_array_count``) so the user can type a
# value directly instead of clicking +/- repeatedly. Renders the same # value directly instead of clicking +/- repeatedly. Renders the same
# ``xN`` glyph as the idle-state per-layer icons for visual consistency. # ``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``.
self.count_label_gizmo = self.gizmos.new("BIM_GT_array_layer_indicator") self.count_label_gizmo = self.gizmos.new("BIM_GT_array_layer_indicator")
self.count_label_gizmo.use_draw_scale = False self.count_label_gizmo.use_draw_scale = False
self.count_label_gizmo.color = default_color self.count_label_gizmo.color = default_color
self.count_label_gizmo.color_highlight = highlight_color self.count_label_gizmo.color_highlight = highlight_color
self.count_label_gizmo.alpha = 0.8 self.count_label_gizmo.alpha = 0.8
self.count_label_gizmo.target_set_operator("bim.input_array_count") self.count_label_gizmo.target_set_operator("bim.input_array_count")
# Destructive delete button at the far right of the edit row — red
# like the minus gizmo so the user reads "destructive" before the
# tooltip even appears. The dispatcher cancels the in-progress edit
# first (clearing edit state + unhiding children) then removes the
# layer in one click.
self.delete_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_trash", self.COLOR_RED, "bim.remove_array_layer_from_edit"
)
# Per-layer ARRAY icons — pre-allocated up to ``MAX_LAYER_GIZMOS`` and # Per-layer ARRAY icons — pre-allocated up to ``MAX_LAYER_GIZMOS`` and
# shown/hidden in ``_refresh_element_specific`` based on the actual # shown/hidden in ``_refresh_element_specific`` based on the actual
@@ -1354,25 +1372,21 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
return match is not None and match.name != "array" return match is not None and match.name != "array"
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props) -> None: def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props) -> None:
"""Position the editing-row extras and the per-layer ARRAY icons. """Position the count label and per-layer ARRAY icons.
Idle (``not props.is_editing``): show one ARRAY icon per existing Idle (``not props.is_editing``): show one ARRAY icon per existing
array layer to the right of the pen. The +/-, method, and editing-row array layer to the right of the pen. The +/-, method, and delete
icons stay hidden. slot icons stay hidden (handled by the base).
Active edit: show the editing row (validate, cancel, / +, method); Active edit: show the count label; hide the per-layer icons. The
hide the per-layer icons so they don't clutter the edit UX.""" +/-, method, and delete icons are positioned by the base class
via the slot layout — no manual positioning needed here."""
icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET
icon_y = self.get_icon_y_offset(context, mw) icon_y = self.get_icon_y_offset(context, mw)
billboard_rot = self._frame_billboard_rot billboard_rot = self._frame_billboard_rot
if not props.is_editing: if not props.is_editing:
# Idle: show one ARRAY icon per existing layer. Per-edit helpers off.
self.count_plus_gizmo.hide = True
self.count_minus_gizmo.hide = True
self.method_gizmo.hide = True
self.count_label_gizmo.hide = True self.count_label_gizmo.hide = True
self.delete_gizmo.hide = True
layers = self._read_array_layers(context) layers = self._read_array_layers(context)
layer_count = min(len(layers), self.MAX_LAYER_GIZMOS) layer_count = min(len(layers), self.MAX_LAYER_GIZMOS)
# Feature-aware start X — pushes the layer icons past any # Feature-aware start X — pushes the layer icons past any
@@ -1399,34 +1413,10 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, scale=0.5) gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, scale=0.5)
return return
# Active edit: hide the layer icons, show the editing row. The # Active edit: hide the layer icons, show the count label. The
# layer-hover bbox lives inside each layer gizmo's draw method, so # +/-, method, and delete slot icons are positioned by the base.
# hiding the gizmos is enough to stop the hover highlight too.
for gz in self.layer_gizmos: for gz in self.layer_gizmos:
gz.hide = True gz.hide = True
# Same Y/Z as the validate/cancel icons set by the base
# ``update_editing_gizmos`` — they form one horizontal row. Helper
# icons (+/-, method) use ``ICON_HELPER_SCALE`` so they look secondary.
for gizmo_name, local_x in (
("count_minus_gizmo", self.ICON_VALIDATE_X + self.ICON_MINUS_X),
("count_plus_gizmo", self.ICON_VALIDATE_X + self.ICON_PLUS_X),
("method_gizmo", self.ICON_VALIDATE_X + self.ICON_METHOD_X),
("delete_gizmo", self.ICON_VALIDATE_X + self.ICON_DELETE_X),
):
gizmo_obj = getattr(self, gizmo_name)
if self.is_gizmo_hidden_by_modal(gizmo_obj):
gizmo_obj.hide = True
continue
gizmo_obj.hide = False
self.set_icon_gizmo_position(
gizmo_name,
mw=mw,
x=local_x,
y=icon_y,
z=icon_z,
billboard_rot=billboard_rot,
scale=self.ICON_HELPER_SCALE,
)
# Clickable world-space ``xN`` between cancel and minus. Mirrors the # Clickable world-space ``xN`` between cancel and minus. Mirrors the
# draft ``props.count`` so the displayed value tracks +/- drags live. # draft ``props.count`` so the displayed value tracks +/- drags live.
if self.is_gizmo_hidden_by_modal(self.count_label_gizmo): if self.is_gizmo_hidden_by_modal(self.count_label_gizmo):
@@ -1492,26 +1482,19 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
return cls._FEATURE_IDLE_MAX_X.get(match.name, 0.0) return cls._FEATURE_IDLE_MAX_X.get(match.name, 0.0)
class GizmoArrayChild(bpy.types.GizmoGroup): class GizmoArrayChild(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
"""Three helper icons surfaced on each array child, mirroring the panel """Two navigation icons on each array child:
actions for that array — Regenerate, Select Parent, Select All Array Objects.
Standalone gizmo group (not a ``BaseParametricGizmoGroup`` subclass) because
the base's ``poll`` early-returns on array children (the mutual-exclusion
safeguard for the per-feature gizmo groups) and none of the editing-lifecycle
scaffolding applies — there's nothing to edit on a managed replica.
Two navigation icons:
- ``VIEW3D_GT_array_parent`` (hierarchy tree) → modifier-aware select via - ``VIEW3D_GT_array_parent`` (hierarchy tree) → modifier-aware select via
``bim.array_parent_gizmo_click``: click selects the parent, Shift+click ``bim.array_parent_gizmo_click``: click selects the parent, Shift+click
selects the whole family, Ctrl+click selects only the children. selects the whole family, Ctrl+click selects only the children.
- ``VIEW3D_GT_array_all`` (2×2 grid) → jump to the parent and enter - ``VIEW3D_GT_array_all`` (2×2 grid) → jump to the parent and enter
array edit (mirrors the per-layer ARRAY icon on the parent, so the array edit.
child has a one-click path into the same edit flow).
Regenerate isn't surfaced here — it's a maintenance action the panel Standalone gizmo group (not a ``BaseParametricGizmoGroup`` subclass)
still exposes, and adding it as a child gizmo just clutters the viewport because the base's ``poll`` early-returns on array children — there's
without giving anything the panel doesn't.""" nothing to edit on a managed replica. Regenerate isn't surfaced as a
child gizmo; the panel still exposes it."""
bl_idname = "OBJECT_GGT_bim_array_child" bl_idname = "OBJECT_GGT_bim_array_child"
bl_label = "Array Child Helpers" bl_label = "Array Child Helpers"
@@ -1525,7 +1508,6 @@ class GizmoArrayChild(bpy.types.GizmoGroup):
ICON_ALL_X = 0.5 ICON_ALL_X = 0.5
ICON_Z_OFFSET = 0.5 ICON_Z_OFFSET = 0.5
ICON_SCALE = 0.5 ICON_SCALE = 0.5
ICON_ALPHA = 0.8
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
@@ -1542,32 +1524,15 @@ class GizmoArrayChild(bpy.types.GizmoGroup):
return tool.Blender.Modifier.is_array_child(element) return tool.Blender.Modifier.is_array_child(element)
def setup(self, context: bpy.types.Context) -> None: def setup(self, context: bpy.types.Context) -> None:
prefs = tool.Blender.get_addon_preferences() default_color, highlight_color = self.get_unselected_decoration_colors()
default_color = prefs.decorator_color_unselected[:3] self.parent_gizmo = self.setup_icon_gizmo(
highlight_color = prefs.decorator_color_selected[:3]
self.parent_gizmo = self._make_icon(
"VIEW3D_GT_array_parent", default_color, highlight_color, "bim.array_parent_gizmo_click" "VIEW3D_GT_array_parent", default_color, highlight_color, "bim.array_parent_gizmo_click"
) )
self.all_gizmo = self._make_icon( self.all_gizmo = self.setup_icon_gizmo(
"VIEW3D_GT_array_all", default_color, highlight_color, "bim.edit_array_from_child" "VIEW3D_GT_array_all", default_color, highlight_color, "bim.edit_array_from_child"
) )
def _make_icon( def position_gizmos(self, context: bpy.types.Context) -> None:
self,
gizmo_type: str,
color: tuple[float, float, float],
highlight_color: tuple[float, float, float],
operator: str,
) -> bpy.types.Gizmo:
gz = self.gizmos.new(gizmo_type)
gz.use_draw_scale = False
gz.color = color
gz.color_highlight = highlight_color
gz.alpha = self.ICON_ALPHA
gz.target_set_operator(operator)
return gz
def draw_prepare(self, context: bpy.types.Context) -> None:
obj = context.active_object obj = context.active_object
if obj is None or not obj.bound_box: if obj is None or not obj.bound_box:
return return
+87 -44
View File
@@ -31,7 +31,7 @@ from mathutils import Matrix, 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.tool.numeric_input import ( from bonsai.tool.numeric_input import (
IntegerInputState, IntegerInputState,
run_integer_input_modal, run_integer_input_modal,
@@ -39,7 +39,7 @@ from bonsai.tool.numeric_input import (
) )
V_ = tool.Blender.V_ V_ = tool.Blender.V_
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, ClassVar
from bmesh.types import BMVert from bmesh.types import BMVert
from bpy.props import IntProperty from bpy.props import IntProperty
@@ -462,16 +462,41 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
bl_region_type = "WINDOW" bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT"} bl_options = {"3D", "PERSISTENT"}
# === Stair-Specific Icon Layout (meters) === # === Stair-Specific Icon Layout ===
# Additional icons for stair editing, positioned after standard icons: # Row order: [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus]
# [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus] # The base class assigns X positions from ``feature_slots`` tuple order —
ICON_TREAD_LOCK_X = 1.24 # X position for tread lock toggle icon # adding an icon is a one-line append, no hardcoded X constant.
ICON_PLUS_X = 1.61 # X position for add tread (+) icon
ICON_MINUS_X = 1.98 # X position for remove tread (-) icon
ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger) ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger)
ICON_CYCLE_SCALE = 0.3 # Scale for cycle type icon ICON_CYCLE_SCALE = 0.3 # Scale for cycle type icon
ICON_Z_OFFSET = 0.5 # Z offset above geometry for editing icons ICON_Z_OFFSET = 0.5 # Z offset above geometry for editing icons
feature_slots: ClassVar[tuple[IconSlot, ...]] = (
IconSlot(
name="tread_lock",
gizmo_idname="VIEW3D_GT_lock",
variants=("open", "closed"),
operator="bim.toggle_stair_property",
color=(1.0, 1.0, 1.0),
operator_props=(("property_name", "custom_tread_lock"),),
),
IconSlot(
name="plus",
gizmo_idname="VIEW3D_GT_plus",
operator="bim.adjust_stair_treads",
scale=ICON_PLUS_MINUS_SCALE,
color=(0.1, 0.8, 0.1),
operator_props=(("increment", 1),),
),
IconSlot(
name="minus",
gizmo_idname="VIEW3D_GT_minus",
operator="bim.adjust_stair_treads",
scale=ICON_PLUS_MINUS_SCALE,
color=(1.0, 0.2, 0.2),
operator_props=(("increment", -1),),
),
)
enable_editing_operator = "bim.enable_editing_stair" enable_editing_operator = "bim.enable_editing_stair"
finish_editing_operator = "bim.finish_editing_stair" finish_editing_operator = "bim.finish_editing_stair"
cancel_editing_operator = "bim.cancel_editing_stair" cancel_editing_operator = "bim.cancel_editing_stair"
@@ -588,25 +613,16 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
return tool.Blender.Modifier.is_stair(element) return tool.Blender.Modifier.is_stair(element)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Create stair-specific icon gizmos (lock, plus, minus).""" """Create the total-length lock as an open/closed pair. Click toggles
self.lock_gizmo = self.create_icon_gizmo( ``props.total_length_lock``; the per-frame update hook picks which
"VIEW3D_GT_lock", 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."""
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, self.COLOR_BLUE,
"bim.toggle_stair_property",
property_name="total_length_lock", property_name="total_length_lock",
) )
self.tread_lock_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_lock",
(1.0, 1.0, 1.0),
"bim.toggle_stair_property",
property_name="custom_tread_lock",
)
self.plus_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_plus", self.COLOR_GREEN, "bim.adjust_stair_treads", increment=1
)
self.minus_gizmo = self.create_icon_gizmo(
"VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1
)
def _refresh_element_specific( def _refresh_element_specific(
self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002 self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002
@@ -618,19 +634,38 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
self.update_tread_count_gizmos(props) self.update_tread_count_gizmos(props)
def update_lock_gizmo(self, props: "BIMStairProperties") -> None: def update_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update lock gizmo color and visibility. Positioning is handled """Show the open/closed total-length lock variant matching
per-frame by the dimension-positioning hook.""" ``props.total_length_lock``. Positioning is handled per-frame by
gizmo_prefs = self.get_gizmo_prefs() the dimension-positioning hook."""
if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock): if not hasattr(self, "total_length_lock_open_gizmo"):
return # Hidden, skip color update
self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions."""
if not hasattr(self, "tread_lock_gizmo"):
return return
gizmo_prefs = self.get_gizmo_prefs() gizmo_prefs = self.get_gizmo_prefs()
self.update_gizmo_visibility(self.tread_lock_gizmo, props.is_editing, gizmo_prefs.lock) visible = props.is_editing and gizmo_prefs.lock
if not visible:
self.total_length_lock_open_gizmo.hide = True
self.total_length_lock_closed_gizmo.hide = True
return
self.total_length_lock_open_gizmo.hide = props.total_length_lock
self.total_length_lock_closed_gizmo.hide = not props.total_length_lock
def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None:
"""Show the open/closed lock variant matching ``props.custom_tread_lock``.
Both pair members share an X position (set by the base's slot
positioning); this picks which one is visible per frame so a state
flip can't reveal both at once."""
if not hasattr(self, "tread_lock_open_gizmo"):
return
gizmo_prefs = self.get_gizmo_prefs()
visible = props.is_editing and gizmo_prefs.lock
# When the pref or edit state hides the slot, hide both members; the
# base loop already wrote their matrices so re-showing later is safe.
if not visible:
self.tread_lock_open_gizmo.hide = True
self.tread_lock_closed_gizmo.hide = True
return
self.tread_lock_open_gizmo.hide = props.custom_tread_lock
self.tread_lock_closed_gizmo.hide = not props.custom_tread_lock
def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None: 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 +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions."""
@@ -719,10 +754,12 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
billboard_rot: Matrix, billboard_rot: Matrix,
total_run: float, total_run: float,
) -> None: ) -> None:
"""Update lock gizmo position based on Y view direction.""" """Update lock gizmo pair position based on Y view direction. Writes
the matrix on both members so a state flip can't reveal a stale pose."""
y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True) y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True)
self.set_icon_gizmo_position( self.set_icon_gizmo_pair_position(
"lock_gizmo", "total_length_lock_open_gizmo",
"total_length_lock_closed_gizmo",
mw, mw,
total_run + self.ICON_Z_OFFSET, total_run + self.ICON_Z_OFFSET,
y_pos, y_pos,
@@ -734,30 +771,36 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
def _update_editing_icon_positions( def _update_editing_icon_positions(
self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix
) -> None: ) -> None:
"""Update editing icon positions, flipping Y based on viewing angle.""" """Reposition the editing icons at stair's view-dependent Y. The base
class's update_editing_gizmos already placed them at the default
``get_icon_y_offset`` Y this overrides with the stair-specific
``get_icon_y_for_view`` flip so the icons land on the side the
camera is looking from."""
if not props.is_editing: if not props.is_editing:
return return
icon_z = props.height + self.ICON_Z_OFFSET icon_z = props.height + self.ICON_Z_OFFSET
y_pos = self.get_icon_y_for_view(props, viewing_from_negative_y) y_pos = self.get_icon_y_for_view(props, viewing_from_negative_y)
slot_x = self._slot_x_positions()
self.set_icon_gizmo_position("validate_gizmo", mw, 0, y_pos, icon_z, billboard_rot) self.set_icon_gizmo_position("validate_gizmo", mw, 0, y_pos, icon_z, billboard_rot)
self.set_icon_gizmo_position("cancel_gizmo", mw, self.ICON_CANCEL_X, y_pos, icon_z, billboard_rot) self.set_icon_gizmo_position("cancel_gizmo", mw, self.ICON_CANCEL_X, y_pos, icon_z, billboard_rot)
self.set_icon_gizmo_position( self.set_icon_gizmo_position(
"cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=self.ICON_CYCLE_SCALE "cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=self.ICON_CYCLE_SCALE
) )
self.set_icon_gizmo_position( self.set_icon_gizmo_pair_position(
"tread_lock_gizmo", "tread_lock_open_gizmo",
"tread_lock_closed_gizmo",
mw, mw,
self.ICON_TREAD_LOCK_X, slot_x["tread_lock"],
y_pos, y_pos,
icon_z - self.EDITING_ICON_SCALE / 2, icon_z - self.EDITING_ICON_SCALE / 2,
billboard_rot, billboard_rot,
scale=self.EDITING_ICON_SCALE, scale=self.EDITING_ICON_SCALE,
) )
self.set_icon_gizmo_position( self.set_icon_gizmo_position(
"plus_gizmo", mw, self.ICON_PLUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE "plus_gizmo", mw, slot_x["plus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
) )
self.set_icon_gizmo_position( self.set_icon_gizmo_position(
"minus_gizmo", mw, self.ICON_MINUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE "minus_gizmo", mw, slot_x["minus"], y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE
) )
+67 -101
View File
@@ -53,7 +53,7 @@ import bonsai.core.root
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim.ifc import IfcStore from bonsai.bim.ifc import IfcStore
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 import preview_base from bonsai.bim.module.model import preview_base
from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator
from bonsai.bim.module.model.polyline import PolylineOperator from bonsai.bim.module.model.polyline import PolylineOperator
@@ -1988,19 +1988,27 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
(0, 0, 1), (0, 0, 1),
) )
# X offsets in the editing icon row, additive from ICON_VALIDATE_X (0.0). # Row layout: validate / cancel / baseline-triplet / rotate / array.
# Matches the cadence used by the base class (0.0 / 0.5 / 0.87 = step ≈ 0.37). # Wall has no ``cycle_type_operator``, so the cycle slot collapses and
# The baseline icons (EXT / CEN / INT) all share ICON_CYCLE_X — only one is # the baseline triplet takes the cycle X position (0.87). Rotate
# ever visible at a time so they don't overlap. # follows at 1.24. Both slots are declared here — the layout manager
ICON_ROTATE_X = 1.24 # assigns the X positions from tuple order.
feature_slots: ClassVar[tuple[IconSlot, ...]] = (
# Mapping from BIMWallProperties.desired_offset_baseline value to the IconSlot(
# attribute on `self` that holds the corresponding state icon. name="baseline",
_BASELINE_GIZMO_ATTRS: ClassVar[dict[str, str]] = { gizmo_idname="VIEW3D_GT_offset",
"EXTERIOR": "offset_exterior_gizmo", variants=("exterior", "center", "interior"),
"CENTER": "offset_center_gizmo", operator="bim.cycle_wall_offset",
"INTERIOR": "offset_interior_gizmo", visibility_pref="cycle",
} ),
IconSlot(
name="rotate",
gizmo_idname="VIEW3D_GT_cycle",
operator="bim.rotate_wall_90",
scale=0.30,
visibility_pref="rotate",
),
)
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.
@@ -2015,16 +2023,15 @@ 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.
Icon-row (always visible during edit mode, fixed position): Idle-row icon outside the toolbar slot system:
- ``offset_{exterior,center,interior}_gizmo`` three state-specific icons, - ``toggle_openings_gizmo`` toggles opening fill visibility (Alt+O),
only one visible at a time. Reflects ``props.desired_offset_baseline``. surfaced in idle state next to the pen.
Clicking any of them cycles the baseline (the operator is the same).
- ``rotate_gizmo`` rotates the wall 90° around Z (Shift+R). Uses the The baseline-state triplet (exterior/center/interior) and the rotate-90
revolving-arrows icon now that the cycle slot is occupied by the icon live in ``feature_slots`` the base class handles creation and
stateful baseline icons. edit-row positioning; this group only picks variant visibility per
- ``toggle_openings_gizmo`` toggles opening fill visibility (Alt+O). frame in ``_update_icon_row_extras``."""
"""
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",
@@ -2044,26 +2051,6 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"bim.extend_wall_height_to_cursor", "bim.extend_wall_height_to_cursor",
highlight_color, highlight_color,
) )
# Three baseline-state icons — only one is visible at a time, picked by
# the current props.desired_offset_baseline. All point to the same cycle
# operator so clicking any of them advances the cycle.
for baseline, attr_name in self._BASELINE_GIZMO_ATTRS.items():
setattr(
self,
attr_name,
self._setup_icon_gizmo(
f"VIEW3D_GT_offset_{baseline.lower()}",
default_color,
"bim.cycle_wall_offset",
highlight_color,
),
)
self.rotate_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_cycle",
default_color,
"bim.rotate_wall_90",
highlight_color,
)
self.toggle_openings_gizmo = self._setup_icon_gizmo( self.toggle_openings_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_add_opening", "VIEW3D_GT_add_opening",
default_color, default_color,
@@ -2132,57 +2119,48 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot) gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
_apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot) _apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot)
# Map ``props.desired_offset_baseline`` (storage form) to the slot variant
# name. Centralised here so the variant strings stay aligned with the slot
# declaration in feature_slots.
_BASELINE_TO_VARIANT: ClassVar[dict[str, str]] = {
"EXTERIOR": "exterior",
"CENTER": "center",
"INTERIOR": "interior",
}
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:
"""Position the wall-specific icons in the icon row. """Pick which baseline variant is visible during edit, and position
the idle-row toggle-openings icon.
Edit-mode icons (visible only when ``props.is_editing``): 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.
- Three baseline icons (Exterior / Centreline / Interior) share the cycle Toggle-openings is NOT in the slot system it surfaces in IDLE
slot only the one matching ``props.desired_offset_baseline`` shows. state (alongside the pen, not in the edit row), so it's positioned
- Rotate-90 icon at ``ICON_ROTATE_X``. manually here."""
if not hasattr(self, "toggle_openings_gizmo"):
Non-edit-mode icons (visible alongside the pen icon, hidden during edit):
- Toggle-openings icon next to the pen. Lives outside edit mode because
opening visibility is a viewport-display concern, not a wall-edit action.
Calls ``billboarded_at`` directly rather than routing through
``set_icon_gizmo_position`` because the icon row has wall-specific
visibility/state branching (baseline-indicator selection, edit-mode
toggle for opening-visibility) that the helper does not model."""
if not hasattr(self, "rotate_gizmo"):
return return
gizmo_prefs = self.get_gizmo_prefs() gizmo_prefs = self.get_gizmo_prefs()
icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET
icon_y = self.get_icon_y_offset(context, mw) icon_y = self.get_icon_y_offset(context, mw)
billboard_rot = self._frame_billboard_rot billboard_rot = self._frame_billboard_rot
# --- Edit-mode icons (baseline indicator + rotate-90) --- # --- Baseline variant visibility ---
if props.is_editing: active_variant = self._BASELINE_TO_VARIANT.get(props.desired_offset_baseline)
# Stateful baseline indicator at the cycle slot. Show exactly one of the for variant in ("exterior", "center", "interior"):
# three icons (the one matching the current baseline), hide the others. gz = getattr(self, f"baseline_{variant}_gizmo", None)
for baseline, attr in self._BASELINE_GIZMO_ATTRS.items(): if gz is None:
gz = getattr(self, attr) continue
if gizmo_prefs.cycle and baseline == props.desired_offset_baseline: if props.is_editing and gizmo_prefs.cycle and variant == active_variant:
gz.hide = self.is_gizmo_hidden_by_modal(gz) gz.hide = self.is_gizmo_hidden_by_modal(gz)
world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_CYCLE_X, icon_y, icon_z))
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot)
else:
gz.hide = True
if gizmo_prefs.rotate:
self.rotate_gizmo.hide = self.is_gizmo_hidden_by_modal(self.rotate_gizmo)
world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_ROTATE_X, icon_y, icon_z))
# VIEW3D_GT_cycle is authored for the base class's 0.30 scale; at 0.5
# it looks roughly 2x too big next to the validate / cancel icons.
self.rotate_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, scale=0.30)
else: else:
self.rotate_gizmo.hide = True gz.hide = True
else:
for attr in self._BASELINE_GIZMO_ATTRS.values():
getattr(self, attr).hide = True
self.rotate_gizmo.hide = True
# --- Non-edit-mode icons (toggle openings) --- # --- Idle-row toggle-openings (outside the slot system) ---
# Sits at the slot the cancel icon occupies during editing — that way the # Sits at the slot the cancel icon occupies during editing — that way the
# pen + openings pair is compact and visually grouped. # pen + openings pair is compact and visually grouped.
if not props.is_editing and gizmo_prefs.toggle_openings: if not props.is_editing and gizmo_prefs.toggle_openings:
@@ -3309,9 +3287,7 @@ class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
return True return True
def setup(self, context: bpy.types.Context) -> None: def setup(self, context: bpy.types.Context) -> None:
prefs = tool.Blender.get_addon_preferences() default_color, highlight_color = self.get_decoration_colors()
default_color = prefs.decorations_colour[:3]
highlight_color = prefs.decorator_color_selected[:3]
self.add_opening_icon = self.setup_icon_gizmo( self.add_opening_icon = self.setup_icon_gizmo(
"VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening" "VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening"
) )
@@ -3376,9 +3352,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
return True return True
def setup(self, context: bpy.types.Context) -> None: def setup(self, context: bpy.types.Context) -> None:
prefs = tool.Blender.get_addon_preferences() default_color, highlight_color = self.get_decoration_colors()
default_color = prefs.decorations_colour[:3]
highlight_color = prefs.decorator_color_selected[:3]
self.extend_vertical_icon = self.setup_icon_gizmo( self.extend_vertical_icon = self.setup_icon_gizmo(
"VIEW3D_GT_extend_vertical", "VIEW3D_GT_extend_vertical",
default_color, default_color,
@@ -3456,9 +3430,7 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
ICON_STACK_OFFSET_Y: ClassVar[float] = 0.4 ICON_STACK_OFFSET_Y: ClassVar[float] = 0.4
def setup(self, context: bpy.types.Context) -> None: def setup(self, context: bpy.types.Context) -> None:
prefs = tool.Blender.get_addon_preferences() default_color, highlight_color = self.get_decoration_colors()
default_color = prefs.decorations_colour[:3]
highlight_color = prefs.decorator_color_selected[:3]
self.unjoin_icon = self.setup_icon_gizmo("VIEW3D_GT_split", default_color, highlight_color, "bim.unjoin_walls") self.unjoin_icon = self.setup_icon_gizmo("VIEW3D_GT_split", default_color, highlight_color, "bim.unjoin_walls")
self.merge_icon = self.setup_icon_gizmo("VIEW3D_GT_merge", default_color, highlight_color, "bim.merge_wall") self.merge_icon = self.setup_icon_gizmo("VIEW3D_GT_merge", default_color, highlight_color, "bim.merge_wall")
self.join_icon = self.setup_icon_gizmo( self.join_icon = self.setup_icon_gizmo(
@@ -3618,9 +3590,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
return True return True
def setup(self, context: bpy.types.Context) -> None: def setup(self, context: bpy.types.Context) -> None:
prefs = tool.Blender.get_addon_preferences() default_color, highlight_color = self.get_decoration_colors()
default_color = prefs.decorations_colour[:3]
highlight_color = prefs.decorator_color_selected[:3]
# Bind the operator on each pool icon ONCE at setup time and keep the returned # Bind the operator on each pool icon ONCE at setup time and keep the returned
# OperatorProperties handles. target_set_operator allocates a fresh handle on # OperatorProperties handles. target_set_operator allocates a fresh handle on
# every call, so calling it from position_gizmos (which fires every redraw # every call, so calling it from position_gizmos (which fires every redraw
@@ -3720,9 +3690,7 @@ class GizmoWallFilletPreview(bpy.types.GizmoGroup):
return True return True
def setup(self, context: bpy.types.Context) -> None: def setup(self, context: bpy.types.Context) -> None:
prefs = tool.Blender.get_addon_preferences() default_color, highlight_color = self.get_decoration_colors()
default_color = tuple(prefs.decorations_colour[:3])
highlight_color = tuple(prefs.decorator_color_selected[:3])
# Lazy-fetched closures re-resolve the Scene per call so the freed-RNA # Lazy-fetched closures re-resolve the Scene per call so the freed-RNA
# crash on file open / undo doesn't hit the gizmo callbacks. # crash on file open / undo doesn't hit the gizmo callbacks.
@@ -3991,9 +3959,7 @@ class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
return tool.Parametric.is_fillet_corner_wall(element) return tool.Parametric.is_fillet_corner_wall(element)
def setup(self, context: bpy.types.Context) -> None: def setup(self, context: bpy.types.Context) -> None:
prefs = tool.Blender.get_addon_preferences() default_color, highlight_color = self.get_decoration_colors()
default_color = prefs.decorations_colour[:3]
highlight_color = prefs.decorator_color_selected[:3]
self.edit_icon = self.setup_icon_gizmo( self.edit_icon = self.setup_icon_gizmo(
"VIEW3D_GT_pen", "VIEW3D_GT_pen",
default_color, default_color,