From 4cf34b69d2e7ad41c822b02e5113e7e724e5e54e Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 1 Jun 2026 16:19:42 +0200 Subject: [PATCH] Replace hardcoded icon-X constants with IconSlot layout manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parametric edit toolbar row used to assign each feature icon its own ICON__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 _. * 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. --- .../bonsai/bim/module/drawing/gizmos.py | 221 ++++++++++++++++-- src/bonsai/bonsai/bim/module/model/array.py | 171 ++++++-------- src/bonsai/bonsai/bim/module/model/stair.py | 131 +++++++---- src/bonsai/bonsai/bim/module/model/wall.py | 168 ++++++------- 4 files changed, 427 insertions(+), 264 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index c654a78850..86e3642c81 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -4924,12 +4924,116 @@ class BillboardingGizmoGroupMixin: """Convenience wrapper over `setup_icon_gizmo` for subclasses.""" 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: raise NotImplementedError( 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 ``_`` 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._gizmo`` for single slots, ``self.__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 _) 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: """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_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 - # Rightmost local-X used by feature-specific icons (across both idle and - # edit states). Subclasses override when they add icons past the cycle - # slot at 0.87 — currently wall (rotate at 1.24) and stair (minus at - # 1.98). Drives both the ARRAY button position (this class) AND the - # array-layer-icons start position (``GizmoArrayEdition`` runtime lookup), - # so non-colliding features get a tight layout while wall / stair shift - # the array-related slots outward to avoid stomping on the rotate / - # tread-lock / +/- icons. - 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). + # Subclasses append to declare feature icons in the edit-mode toolbar row. + # The layout manager assigns each slot an X position from its tuple + # index — adding a new icon is a one-line append, no hardcoded X + # constant, no "remember to bump the right edge" rule. The trailing + # ARRAY button is positioned past the last slot automatically. + feature_slots: ClassVar[tuple[IconSlot, ...]] = () + # Gap between adjacent slots past the leading validate/cancel/cycle + # triplet, AND between the last slot and the ARRAY button. ICON_ARRAY_GAP: float = 0.37 ICON_Z_OFFSET = 0.5 # Height above element for icons ICON_Y_OFFSET = GIZMO_OFFSET * 2 # Y offset to keep icons clear of geometry @@ -5060,6 +5161,37 @@ class BaseParametricGizmoGroup: super().__init_subclass__(**kwargs) 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 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 @@ -5762,6 +5894,17 @@ class BaseParametricGizmoGroup: "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 # ``update_editing_gizmos``). Click commits the current edit and adds a # Blender-vanilla-defaulted array (count=2, X-offset = bbox extent). The @@ -6024,10 +6167,51 @@ class BaseParametricGizmoGroup: billboard_rot=billboard_rot, scale=0.30, ) - # ARRAY button sits past the last feature-specific icon. Each - # gizmo group declares its own ``FEATURE_ICON_MAX_X`` (default - # 0.87 past the cycle slot; wall / stair override it) so the - # ARRAY button never lands on top of a rotate / tread-lock icon. + # Feature slots: per-class IconSlot tuples driven by tuple order. + # Hidden slots STILL CONSUME their X position — toggling a pref + # mustn't reflow the row (otherwise the array button drifts left + # 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"): 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): @@ -6037,7 +6221,7 @@ class BaseParametricGizmoGroup: self.set_icon_gizmo_position( "array_gizmo", 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, z=icon_z, billboard_rot=billboard_rot, @@ -6061,6 +6245,11 @@ class BaseParametricGizmoGroup: self.cancel_gizmo.hide = True if self.cycle_type_operator or self.pick_type_operator: 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"): self.array_gizmo.hide = True diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index 648fc760ce..56807f1421 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -28,7 +28,7 @@ from mathutils import Matrix, Vector import bonsai.bim.module.drawing.gizmos as gizmo 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 @@ -1132,22 +1132,53 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): # collapses to a single per-feature pen. hide_pen_button = True - # Local-X positions of the editing-row extras (count label + adjuster icons). - # Layout left-to-right at the same Y/Z as validate (ICON_VALIDATE_X = 0.0) and - # cancel (ICON_VALIDATE_X + ICON_CANCEL_X = 0.5): - # validate | cancel | xN | - | + | method-toggle | trash. - # Spacing mirrors stair's editing-row constants so the icons match in visual rhythm. - # Trash sits past the method toggle with a slightly wider gap so the destructive - # action stays visually separated from the routine edit controls. + # 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 - 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 # 0.5 used for validate/cancel. Makes the helpers look secondary. 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 # 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 @@ -1249,36 +1280,23 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): return tool.Parametric.is_array(element) def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: - """Create the +/- count adjusters, the method toggle, and the - per-layer ARRAY entry icons (one per existing array layer).""" - self.count_plus_gizmo = self.create_icon_gizmo( - "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". + """Create the world-space count label and per-layer ARRAY entry icons. + The +/- count adjusters, method toggle, and delete button live in + ``feature_slots`` and are auto-created by the base class.""" 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 # 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``. 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 self.count_label_gizmo.color_highlight = highlight_color self.count_label_gizmo.alpha = 0.8 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 # 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" 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 - array layer to the right of the pen. The +/-, method, and editing-row - icons stay hidden. + array layer to the right of the pen. The +/-, method, and delete + slot icons stay hidden (handled by the base). - Active edit: show the editing row (validate, cancel, − / +, method); - hide the per-layer icons so they don't clutter the edit UX.""" + Active edit: show the count label; hide the per-layer icons. The + +/-, 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_y = self.get_icon_y_offset(context, mw) billboard_rot = self._frame_billboard_rot 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.delete_gizmo.hide = True layers = self._read_array_layers(context) layer_count = min(len(layers), self.MAX_LAYER_GIZMOS) # 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) return - # Active edit: hide the layer icons, show the editing row. The - # layer-hover bbox lives inside each layer gizmo's draw method, so - # hiding the gizmos is enough to stop the hover highlight too. + # Active edit: hide the layer icons, show the count label. The + # +/-, method, and delete slot icons are positioned by the base. for gz in self.layer_gizmos: 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 # draft ``props.count`` so the displayed value tracks +/- drags live. 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) -class GizmoArrayChild(bpy.types.GizmoGroup): - """Three helper icons surfaced on each array child, mirroring the panel - actions for that array — Regenerate, Select Parent, Select All Array Objects. +class GizmoArrayChild(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): + """Two navigation icons on each array child: - 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 ``bim.array_parent_gizmo_click``: click selects the parent, Shift+click selects the whole family, Ctrl+click selects only the children. - ``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 - child has a one-click path into the same edit flow). + array edit. - Regenerate isn't surfaced here — it's a maintenance action the panel - still exposes, and adding it as a child gizmo just clutters the viewport - without giving anything the panel doesn't.""" + Standalone gizmo group (not a ``BaseParametricGizmoGroup`` subclass) + because the base's ``poll`` early-returns on array children — there's + 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_label = "Array Child Helpers" @@ -1525,7 +1508,6 @@ class GizmoArrayChild(bpy.types.GizmoGroup): ICON_ALL_X = 0.5 ICON_Z_OFFSET = 0.5 ICON_SCALE = 0.5 - ICON_ALPHA = 0.8 @classmethod def poll(cls, context): @@ -1542,32 +1524,15 @@ class GizmoArrayChild(bpy.types.GizmoGroup): return tool.Blender.Modifier.is_array_child(element) def setup(self, context: bpy.types.Context) -> None: - prefs = tool.Blender.get_addon_preferences() - default_color = prefs.decorator_color_unselected[:3] - highlight_color = prefs.decorator_color_selected[:3] - self.parent_gizmo = self._make_icon( + default_color, highlight_color = self.get_unselected_decoration_colors() + self.parent_gizmo = self.setup_icon_gizmo( "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" ) - def _make_icon( - 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: + def position_gizmos(self, context: bpy.types.Context) -> None: obj = context.active_object if obj is None or not obj.bound_box: return diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index ef765ba53c..9d9b87b92d 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -31,7 +31,7 @@ from mathutils import Matrix, Vector import bonsai.core.root import bonsai.tool as tool from bonsai.bim.module.drawing import gizmos as gizmo -from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig +from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot from bonsai.tool.numeric_input import ( IntegerInputState, run_integer_input_modal, @@ -39,7 +39,7 @@ from bonsai.tool.numeric_input import ( ) V_ = tool.Blender.V_ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar from bmesh.types import BMVert from bpy.props import IntProperty @@ -462,16 +462,41 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): bl_region_type = "WINDOW" bl_options = {"3D", "PERSISTENT"} - # === Stair-Specific Icon Layout (meters) === - # Additional icons for stair editing, positioned after standard icons: - # [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus] - ICON_TREAD_LOCK_X = 1.24 # X position for tread lock toggle icon - ICON_PLUS_X = 1.61 # X position for add tread (+) icon - ICON_MINUS_X = 1.98 # X position for remove tread (-) icon + # === Stair-Specific Icon Layout === + # Row order: [Validate] [Cancel] [Cycle] [TreadLock] [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_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" finish_editing_operator = "bim.finish_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) def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: - """Create stair-specific icon gizmos (lock, plus, minus).""" - self.lock_gizmo = self.create_icon_gizmo( - "VIEW3D_GT_lock", + """Create the total-length lock as an open/closed pair. 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.""" + 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, - "bim.toggle_stair_property", 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( 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) def update_lock_gizmo(self, props: "BIMStairProperties") -> None: - """Update lock gizmo color and visibility. Positioning is handled - per-frame by the dimension-positioning hook.""" - gizmo_prefs = self.get_gizmo_prefs() - if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock): - 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"): + """Show the open/closed total-length lock variant matching + ``props.total_length_lock``. Positioning is handled per-frame by + the dimension-positioning hook.""" + if not hasattr(self, "total_length_lock_open_gizmo"): return 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: """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, total_run: float, ) -> 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) - self.set_icon_gizmo_position( - "lock_gizmo", + self.set_icon_gizmo_pair_position( + "total_length_lock_open_gizmo", + "total_length_lock_closed_gizmo", mw, total_run + self.ICON_Z_OFFSET, y_pos, @@ -734,30 +771,36 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): def _update_editing_icon_positions( self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix ) -> 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: return icon_z = props.height + self.ICON_Z_OFFSET 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("cancel_gizmo", mw, self.ICON_CANCEL_X, y_pos, icon_z, billboard_rot) self.set_icon_gizmo_position( "cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=self.ICON_CYCLE_SCALE ) - self.set_icon_gizmo_position( - "tread_lock_gizmo", + self.set_icon_gizmo_pair_position( + "tread_lock_open_gizmo", + "tread_lock_closed_gizmo", mw, - self.ICON_TREAD_LOCK_X, + slot_x["tread_lock"], y_pos, icon_z - self.EDITING_ICON_SCALE / 2, billboard_rot, scale=self.EDITING_ICON_SCALE, ) 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( - "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 ) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 8b51909ce1..016e36ddcd 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -53,7 +53,7 @@ import bonsai.core.root import bonsai.tool as tool from bonsai.bim.ifc import IfcStore 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.decorator import PolylineDecorator, ProductDecorator from bonsai.bim.module.model.polyline import PolylineOperator @@ -1988,19 +1988,27 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): (0, 0, 1), ) - # X offsets in the editing icon row, additive from ICON_VALIDATE_X (0.0). - # Matches the cadence used by the base class (0.0 / 0.5 / 0.87 = step ≈ 0.37). - # The baseline icons (EXT / CEN / INT) all share ICON_CYCLE_X — only one is - # ever visible at a time so they don't overlap. - ICON_ROTATE_X = 1.24 - - # Mapping from BIMWallProperties.desired_offset_baseline value to the - # attribute on `self` that holds the corresponding state icon. - _BASELINE_GIZMO_ATTRS: ClassVar[dict[str, str]] = { - "EXTERIOR": "offset_exterior_gizmo", - "CENTER": "offset_center_gizmo", - "INTERIOR": "offset_interior_gizmo", - } + # Row layout: validate / cancel / baseline-triplet / rotate / array. + # Wall has no ``cycle_type_operator``, so the cycle slot collapses and + # the baseline triplet takes the cycle X position (0.87). Rotate + # follows at 1.24. Both slots are declared here — the layout manager + # assigns the X positions from tuple order. + feature_slots: ClassVar[tuple[IconSlot, ...]] = ( + IconSlot( + name="baseline", + gizmo_idname="VIEW3D_GT_offset", + variants=("exterior", "center", "interior"), + operator="bim.cycle_wall_offset", + 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: """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 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, - only one visible at a time. Reflects ``props.desired_offset_baseline``. - Clicking any of them cycles the baseline (the operator is the same). - - ``rotate_gizmo`` — rotates the wall 90° around Z (Shift+R). Uses the - revolving-arrows icon now that the cycle slot is occupied by the - stateful baseline icons. - - ``toggle_openings_gizmo`` — toggles opening fill visibility (Alt+O). - """ + - ``toggle_openings_gizmo`` — toggles opening fill visibility (Alt+O), + surfaced in idle state next to the pen. + + The baseline-state triplet (exterior/center/interior) and the rotate-90 + icon live in ``feature_slots`` — the base class handles creation and + edit-row positioning; this group only picks variant visibility per + frame in ``_update_icon_row_extras``.""" default_color, highlight_color = self.get_decoration_colors() self.split_gizmo = self._setup_icon_gizmo( "VIEW3D_GT_split", @@ -2044,26 +2051,6 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): "bim.extend_wall_height_to_cursor", 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( "VIEW3D_GT_add_opening", default_color, @@ -2132,57 +2119,48 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): gz.matrix_basis = gizmo.billboarded_at(world_pos, 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: - """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 - slot — only the one matching ``props.desired_offset_baseline`` shows. - - Rotate-90 icon at ``ICON_ROTATE_X``. - - 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"): + 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 + manually here.""" + if not hasattr(self, "toggle_openings_gizmo"): return gizmo_prefs = self.get_gizmo_prefs() icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET icon_y = self.get_icon_y_offset(context, mw) billboard_rot = self._frame_billboard_rot - # --- Edit-mode icons (baseline indicator + rotate-90) --- - if props.is_editing: - # Stateful baseline indicator at the cycle slot. Show exactly one of the - # three icons (the one matching the current baseline), hide the others. - for baseline, attr in self._BASELINE_GIZMO_ATTRS.items(): - gz = getattr(self, attr) - if gizmo_prefs.cycle and baseline == props.desired_offset_baseline: - 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) + # --- Baseline variant visibility --- + active_variant = self._BASELINE_TO_VARIANT.get(props.desired_offset_baseline) + for variant in ("exterior", "center", "interior"): + gz = getattr(self, f"baseline_{variant}_gizmo", None) + if gz is None: + continue + if props.is_editing and gizmo_prefs.cycle and variant == active_variant: + gz.hide = self.is_gizmo_hidden_by_modal(gz) else: - self.rotate_gizmo.hide = True - else: - for attr in self._BASELINE_GIZMO_ATTRS.values(): - getattr(self, attr).hide = True - self.rotate_gizmo.hide = True + gz.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 # pen + openings pair is compact and visually grouped. if not props.is_editing and gizmo_prefs.toggle_openings: @@ -3309,9 +3287,7 @@ class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin return True def setup(self, context: bpy.types.Context) -> None: - prefs = tool.Blender.get_addon_preferences() - default_color = prefs.decorations_colour[:3] - highlight_color = prefs.decorator_color_selected[:3] + default_color, highlight_color = self.get_decoration_colors() self.add_opening_icon = self.setup_icon_gizmo( "VIEW3D_GT_add_opening", default_color, highlight_color, "bim.add_opening" ) @@ -3376,9 +3352,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin return True def setup(self, context: bpy.types.Context) -> None: - prefs = tool.Blender.get_addon_preferences() - default_color = prefs.decorations_colour[:3] - highlight_color = prefs.decorator_color_selected[:3] + default_color, highlight_color = self.get_decoration_colors() self.extend_vertical_icon = self.setup_icon_gizmo( "VIEW3D_GT_extend_vertical", default_color, @@ -3456,9 +3430,7 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin ICON_STACK_OFFSET_Y: ClassVar[float] = 0.4 def setup(self, context: bpy.types.Context) -> None: - prefs = tool.Blender.get_addon_preferences() - default_color = prefs.decorations_colour[:3] - highlight_color = prefs.decorator_color_selected[:3] + default_color, highlight_color = self.get_decoration_colors() 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.join_icon = self.setup_icon_gizmo( @@ -3618,9 +3590,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix return True def setup(self, context: bpy.types.Context) -> None: - prefs = tool.Blender.get_addon_preferences() - default_color = prefs.decorations_colour[:3] - highlight_color = prefs.decorator_color_selected[:3] + default_color, highlight_color = self.get_decoration_colors() # 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 # every call, so calling it from position_gizmos (which fires every redraw @@ -3720,9 +3690,7 @@ class GizmoWallFilletPreview(bpy.types.GizmoGroup): return True def setup(self, context: bpy.types.Context) -> None: - prefs = tool.Blender.get_addon_preferences() - default_color = tuple(prefs.decorations_colour[:3]) - highlight_color = tuple(prefs.decorator_color_selected[:3]) + default_color, highlight_color = self.get_decoration_colors() # Lazy-fetched closures re-resolve the Scene per call so the freed-RNA # 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) def setup(self, context: bpy.types.Context) -> None: - prefs = tool.Blender.get_addon_preferences() - default_color = prefs.decorations_colour[:3] - highlight_color = prefs.decorator_color_selected[:3] + default_color, highlight_color = self.get_decoration_colors() self.edit_icon = self.setup_icon_gizmo( "VIEW3D_GT_pen", default_color,