diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index d9055d1f79..fab7646162 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -29,18 +29,6 @@ from bpy_extras.io_utils import ExportHelper, ImportHelper from . import handler, operator, parametric_lifecycle, prop, ui - -def _parametric_gizmo_preference_classes() -> list[type]: - """Resolves the registry-driven ``GizmoPreferences`` classes for the - ``classes`` list below. ``import bonsai.tool`` is kept local to surface - the load-order constraint: it relies on ``from . import handler, …`` - above having primed the - ``tool/ifc.py → bim/ifc.py → bim/handler.py → bonsai.tool`` cycle.""" - import bonsai.tool as tool - - return tool.Parametric.iter_gizmo_preference_classes(ui) - - try: from bonsai.translations import translations_dict except ImportError: @@ -171,10 +159,6 @@ classes = [ ui.BIM_UL_tab_visibilities, ui.BIM_UL_panel_visibilities, ui.DocPreferences, - # Per-parametric-type ``GizmoPreferences`` classes — must register - # before ``ui.GizmoPreferences`` which holds the matching PointerProperty - # fields. Driven by ``tool.Parametric.EDIT_TYPES``. - *_parametric_gizmo_preference_classes(), ui.GizmoPreferences, # ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below) # Tabs panel diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 86e3642c81..2c34ebe759 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -143,6 +143,16 @@ DOOR_SWING_ANGLE_MAX = 90.0 # Default scale factor for billboarded icons (Blender-unit visual size). DEFAULT_BILLBOARD_SCALE = 0.5 +# Shared gizmo color constants. Re-exported as class attributes on +# BaseParametricGizmoGroup so callers can use either ``self.COLOR_GREEN`` +# from inside a gizmo group or the module-level constant from a class body +# (e.g. IconSlot declarations) without a forward-reference issue. Match +# Blender's axis convention: X=red, Y=green, Z=blue. +COLOR_RED = (1.0, 0.2, 0.2) +COLOR_GREEN = (0.1, 0.8, 0.1) +COLOR_BLUE = (0.3, 0.3, 1.0) +COLOR_NEUTRAL = (1.0, 1.0, 1.0) + PRECISION_MODE_MULTIPLIER = 0.1 RAY_CAST_DISTANCE = 1000 @@ -4971,9 +4981,6 @@ class IconSlot: - ``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. @@ -4991,7 +4998,6 @@ class IconSlot: 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], ...] = () @@ -5109,11 +5115,13 @@ class BaseParametricGizmoGroup: """ # === Gizmo Colors === - # Match Blender axis convention: X=red, Y=green, Z=blue - COLOR_RED = (1.0, 0.2, 0.2) - COLOR_GREEN = (0.1, 0.8, 0.1) - COLOR_BLUE = (0.3, 0.3, 1.0) - COLOR_NEUTRAL = (1.0, 1.0, 1.0) + # Aliased to the module-level constants so subclass class bodies can + # reference either spelling. Match Blender's axis convention: + # X=red, Y=green, Z=blue. + COLOR_RED = COLOR_RED + COLOR_GREEN = COLOR_GREEN + COLOR_BLUE = COLOR_BLUE + COLOR_NEUTRAL = COLOR_NEUTRAL # === Dimension Gizmo Layout (meters) === ARROW_SCALE = 0.25 # Scale factor for arrow gizmos @@ -5271,27 +5279,13 @@ class BaseParametricGizmoGroup: from_neg_y, from_neg_x = self.get_local_view_direction(context, world_matrix) return ViewDirection(from_negative_y=from_neg_y, from_negative_x=from_neg_x) - def update_gizmo_visibility(self, gizmo: bpy.types.Gizmo, is_editing: bool, pref_enabled: bool) -> bool: - """Update gizmo visibility based on modal state, editing state, and preference. - - Consolidates the common pattern: - if hidden_by_modal: - gizmo.hide = True - else: - gizmo.hide = not is_editing or not pref_enabled - - Args: - gizmo: The gizmo to update visibility for - is_editing: Whether the element is currently being edited - pref_enabled: Whether this gizmo type is enabled in preferences - - Returns: - True if the gizmo is now visible (not hidden), False otherwise - """ + def update_gizmo_visibility(self, gizmo: bpy.types.Gizmo, is_editing: bool) -> bool: + """Hide ``gizmo`` when not editing or when a modal owns the viewport. + Returns True if the gizmo is now visible.""" if self.is_gizmo_hidden_by_modal(gizmo): gizmo.hide = True return False - gizmo.hide = not is_editing or not pref_enabled + gizmo.hide = not is_editing return not gizmo.hide def get_y_position_for_view( @@ -5533,8 +5527,7 @@ class BaseParametricGizmoGroup: return False if cls.gizmo_pref_name: prefs = tool.Blender.get_addon_preferences() - feature_prefs = getattr(prefs.gizmos, cls.gizmo_pref_name, None) - if feature_prefs is not None and not getattr(feature_prefs, "enabled", True): + if not getattr(prefs.gizmos, cls.gizmo_pref_name, True): return False if len(tool.Blender.get_selected_objects()) != 1: return False @@ -5623,8 +5616,9 @@ class BaseParametricGizmoGroup: """ pass - # Subclass should define these class attributes for metadata-driven dispatch - # If not defined, subclass must override get_props() and get_gizmo_prefs() + # Subclass should define these class attributes for metadata-driven dispatch. + # ``gizmo_pref_name`` matches a flat BoolProperty field on + # ``GizmoPreferences`` and gates the whole gizmo group's poll. props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup] | None = None gizmo_pref_name: str | None = None # e.g., "door" @@ -5659,18 +5653,6 @@ class BaseParametricGizmoGroup: prefs = self.get_addon_prefs() return prefs.decorations_colour[:3], prefs.decorator_color_selected[:3] - def get_gizmo_prefs(self) -> Any: - """Get gizmo preferences for this element type. - - Subclass can either: - 1. Define class attribute `gizmo_pref_name` (e.g., "door") - 2. Override this method directly - """ - if self.gizmo_pref_name: - prefs = self.get_addon_prefs() - return getattr(prefs.gizmos, self.gizmo_pref_name) - raise NotImplementedError("Subclass must define gizmo_pref_name or override get_gizmo_prefs()") - def is_setup_complete(self) -> bool: """Check if gizmo setup has been completed. @@ -6168,23 +6150,13 @@ class BaseParametricGizmoGroup: scale=0.30, ) # 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). + # Whole-feature visibility is gated upstream by ``poll()`` against + # ``prefs.gizmos.``; positioning happens unconditionally + # whenever the gizmo group polls visible. 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 diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index 56807f1421..68bfb9ac4c 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -28,7 +28,12 @@ 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, IconSlot +from bonsai.bim.module.drawing.gizmos import ( + COLOR_GREEN, + COLOR_RED, + DimensionGizmoConfig, + IconSlot, +) from bonsai.bim.parametric_lifecycle import ParametricEditMixinBase @@ -1150,7 +1155,7 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): gizmo_idname="VIEW3D_GT_minus", operator="bim.adjust_array_count", scale=ICON_HELPER_SCALE, - color=(1.0, 0.2, 0.2), + color=COLOR_RED, operator_props=(("increment", -1),), ), IconSlot( @@ -1158,7 +1163,7 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): gizmo_idname="VIEW3D_GT_plus", operator="bim.adjust_array_count", scale=ICON_HELPER_SCALE, - color=(0.1, 0.8, 0.1), + color=COLOR_GREEN, operator_props=(("increment", 1),), ), IconSlot( @@ -1172,7 +1177,7 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): gizmo_idname="VIEW3D_GT_trash", operator="bim.remove_array_layer_from_edit", scale=ICON_HELPER_SCALE, - color=(1.0, 0.2, 0.2), + color=COLOR_RED, # 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, diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index 6ccdf23c97..c9323fa14e 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -893,15 +893,8 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None: """Update swing gizmo position and color based on editing state.""" - prefs = self.get_addon_prefs() - door_gizmo_prefs = prefs.gizmos.door - - door_type_visible = self.update_gizmo_visibility( - self.gizmo_door_type, props.is_editing, door_gizmo_prefs.swing_arc - ) - flip_arc_visible = self.update_gizmo_visibility( - self.gizmo_flip_arc, props.is_editing, door_gizmo_prefs.flip_arc - ) + door_type_visible = self.update_gizmo_visibility(self.gizmo_door_type, props.is_editing) + flip_arc_visible = self.update_gizmo_visibility(self.gizmo_flip_arc, props.is_editing) if not door_type_visible and not flip_arc_visible: return diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index 9d9b87b92d..f1588e06ea 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -31,7 +31,12 @@ 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, IconSlot +from bonsai.bim.module.drawing.gizmos import ( + COLOR_GREEN, + COLOR_RED, + DimensionGizmoConfig, + IconSlot, +) from bonsai.tool.numeric_input import ( IntegerInputState, run_integer_input_modal, @@ -484,7 +489,7 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): gizmo_idname="VIEW3D_GT_plus", operator="bim.adjust_stair_treads", scale=ICON_PLUS_MINUS_SCALE, - color=(0.1, 0.8, 0.1), + color=COLOR_GREEN, operator_props=(("increment", 1),), ), IconSlot( @@ -492,7 +497,7 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): gizmo_idname="VIEW3D_GT_minus", operator="bim.adjust_stair_treads", scale=ICON_PLUS_MINUS_SCALE, - color=(1.0, 0.2, 0.2), + color=COLOR_RED, operator_props=(("increment", -1),), ), ) @@ -639,9 +644,7 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): the dimension-positioning hook.""" if not hasattr(self, "total_length_lock_open_gizmo"): return - gizmo_prefs = self.get_gizmo_prefs() - visible = props.is_editing and gizmo_prefs.lock - if not visible: + if not props.is_editing: self.total_length_lock_open_gizmo.hide = True self.total_length_lock_closed_gizmo.hide = True return @@ -656,11 +659,7 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): 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: + if not props.is_editing: self.tread_lock_open_gizmo.hide = True self.tread_lock_closed_gizmo.hide = True return @@ -671,12 +670,9 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): """Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions.""" if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"): return - gizmo_prefs = self.get_gizmo_prefs() - self.update_gizmo_visibility(self.plus_gizmo, props.is_editing, gizmo_prefs.plus) + self.update_gizmo_visibility(self.plus_gizmo, props.is_editing) # Minus has additional condition: number_of_treads > 1 - self.update_gizmo_visibility( - self.minus_gizmo, props.is_editing and props.number_of_treads > 1, gizmo_prefs.minus - ) + self.update_gizmo_visibility(self.minus_gizmo, props.is_editing and props.number_of_treads > 1) def _update_dimension_gizmo_positions( self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" # noqa: ARG002 diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index ea1f938d0d..bf54646829 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -117,6 +117,7 @@ def regenerate_wall_mesh_from_props(obj: bpy.types.Object) -> None: bm.faces.new([verts[1], verts[5], verts[6], verts[2]]) assert isinstance(obj.data, bpy.types.Mesh) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) bm.to_mesh(obj.data) bm.free() obj.data.update() @@ -1999,14 +2000,12 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): 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", ), ) @@ -2077,7 +2076,6 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): collide with split, so extend-Z gets bumped further to clear it.""" if not hasattr(self, "split_gizmo"): return - gizmo_prefs = self.get_gizmo_prefs() all_gizmos = (self.extend_x_gizmo, self.extend_z_gizmo, self.split_gizmo) if not props.is_editing: for gz in all_gizmos: @@ -2090,13 +2088,12 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): # Candidates ordered by priority (lowest first). Each is (gizmo, local_z). # The local X and Y are common: at the cursor's projected X on the axis. - # Only "active" gizmos (enabled + applicable) participate in placement. - candidates: list[tuple[bpy.types.Gizmo, float]] = [] - if gizmo_prefs.extend: - candidates.append((self.extend_x_gizmo, 0.0)) - if gizmo_prefs.extend_height: - candidates.append((self.extend_z_gizmo, cursor_local.z)) - if in_range and gizmo_prefs.scissors: + # Split only joins when the cursor sits inside the wall's length range. + candidates: list[tuple[bpy.types.Gizmo, float]] = [ + (self.extend_x_gizmo, 0.0), + (self.extend_z_gizmo, cursor_local.z), + ] + if in_range: candidates.append((self.split_gizmo, props.height)) # Resolve collisions: walk in priority order and ensure each gizmo's @@ -2144,7 +2141,6 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): 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 @@ -2155,7 +2151,7 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): 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: + if props.is_editing and variant == active_variant: gz.hide = self.is_gizmo_hidden_by_modal(gz) else: gz.hide = True @@ -2163,7 +2159,7 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): # --- 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: + if not props.is_editing: self.toggle_openings_gizmo.hide = self.is_gizmo_hidden_by_modal(self.toggle_openings_gizmo) world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z)) self.toggle_openings_gizmo.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot) diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 329ce7bd80..2b17990220 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -275,202 +275,33 @@ class BIM_UL_panel_visibilities(bpy.types.UIList): row.prop(item, "is_bookmarked", text="", icon="SOLO_ON" if item.is_bookmarked else "SOLO_OFF", emboss=False) -class GizmoPreferencesDoor(bpy.types.PropertyGroup): - """Property group for door gizmo visibility settings.""" - - overall_height: BoolProperty(name="Overall Height", default=True) - overall_width: BoolProperty(name="Overall Width", default=True) - threshold_thickness: BoolProperty(name="Threshold Thickness", default=True) - threshold_depth: BoolProperty(name="Threshold Depth", default=True) - threshold_offset: BoolProperty(name="Threshold Offset", default=True) - lining_offset: BoolProperty(name="Lining Offset", default=True) - lining_depth: BoolProperty(name="Lining Depth", default=True) - lining_thickness: BoolProperty(name="Lining Thickness", default=True) - transom_offset: BoolProperty(name="Transom Offset", default=True) - transom_thickness: BoolProperty(name="Transom Thickness", default=True) - casing_thickness: BoolProperty(name="Casing Thickness", default=True) - casing_depth: BoolProperty(name="Casing Depth", default=True) - swing_arc: BoolProperty(name="Swing Arc", default=True, description="Show door swing direction arc") - flip_arc: BoolProperty(name="Flip Arc", default=True, description="Show flip door orientation arc") - - if TYPE_CHECKING: - overall_height: bool - overall_width: bool - threshold_thickness: bool - threshold_depth: bool - threshold_offset: bool - lining_offset: bool - lining_depth: bool - lining_thickness: bool - transom_offset: bool - transom_thickness: bool - casing_thickness: bool - casing_depth: bool - swing_arc: bool - flip_arc: bool - - -class GizmoPreferencesWindow(bpy.types.PropertyGroup): - """Property group for window gizmo visibility settings.""" - - overall_height: BoolProperty(name="Overall Height", default=True) - overall_width: BoolProperty(name="Overall Width", default=True) - lining_offset: BoolProperty(name="Lining Offset", default=True) - lining_depth: BoolProperty(name="Lining Depth", default=True) - lining_thickness: BoolProperty(name="Lining Thickness", default=True) - lining_to_panel_offset_x: BoolProperty(name="Lining to Panel Offset X", default=True) - lining_to_panel_offset_y: BoolProperty(name="Lining to Panel Offset Y", default=True) - frame_depth: BoolProperty(name="Frame Depth", default=True) - frame_thickness: BoolProperty(name="Frame Thickness", default=True) - mullion_thickness: BoolProperty(name="Mullion Thickness", default=True) - first_mullion_offset: BoolProperty(name="First Mullion Offset", default=True) - second_mullion_offset: BoolProperty(name="Second Mullion Offset", default=True) - transom_thickness: BoolProperty(name="Transom Thickness", default=True) - first_transom_offset: BoolProperty(name="First Transom Offset", default=True) - second_transom_offset: BoolProperty(name="Second Transom Offset", default=True) - - if TYPE_CHECKING: - overall_height: bool - overall_width: bool - lining_offset: bool - lining_depth: bool - lining_thickness: bool - lining_to_panel_offset_x: bool - lining_to_panel_offset_y: bool - frame_depth: bool - frame_thickness: bool - mullion_thickness: bool - first_mullion_offset: bool - second_mullion_offset: bool - transom_thickness: bool - first_transom_offset: bool - second_transom_offset: bool - - -class GizmoPreferencesStair(bpy.types.PropertyGroup): - """Property group for stair gizmo visibility settings.""" - - width: BoolProperty(name="Width", default=True) - height: BoolProperty(name="Height", default=True) - tread_run: BoolProperty(name="Tread Run", default=True) - tread_depth: BoolProperty(name="Tread Depth", default=True) - riser_height: BoolProperty(name="Riser Height", default=True) - nosing_length: BoolProperty(name="Nosing Length", default=True) - nosing_depth: BoolProperty(name="Nosing Depth", default=True) - total_length_target: BoolProperty(name="Total Length Target", default=True) - base_slab_depth: BoolProperty(name="Base Slab Depth", default=True) - top_slab_depth: BoolProperty(name="Top Slab Depth", default=True) - lock: BoolProperty(name="Total Length Lock", default=True) - plus: BoolProperty(name="Add Tread (+)", default=True) - minus: BoolProperty(name="Remove Tread (-)", default=True) - cycle: BoolProperty(name="Cycle Stair Type", default=True) - - if TYPE_CHECKING: - width: bool - height: bool - tread_run: bool - tread_depth: bool - riser_height: bool - nosing_length: bool - nosing_depth: bool - total_length_target: bool - base_slab_depth: bool - top_slab_depth: bool - lock: bool - plus: bool - minus: bool - cycle: bool - - -class GizmoPreferencesWall(bpy.types.PropertyGroup): - """Property group for wall gizmo visibility settings.""" - - length: BoolProperty( - name="Length", - default=True, - description="Show the length dimension gizmo along the wall axis.", - ) - height: BoolProperty( - name="Height", - default=True, - description="Show the height dimension gizmo at the wall's start endpoint.", - ) - height_end: BoolProperty( - name="Height (far end, walls > 5m)", - default=True, - description=( - "Show a second height gizmo at the wall's far end so long walls don't " - "require panning to reach the handle." - ), - ) - x_angle: BoolProperty( - name="Slope", - default=True, - description="Show the slope gizmo at the wall top measuring horizontal displacement of the top face.", - ) - cycle: BoolProperty( - name="Cycle Offset Baseline", - default=True, - description="Show the baseline-state icon (Exterior / Centreline / Interior) in the editing icon row.", - ) - scissors: BoolProperty( - name="Split at cursor", - default=True, - description="Show the split icon at the 3D cursor when it lies within the wall's length range.", - ) - extend: BoolProperty( - name="Extend length to cursor X", - default=True, - description="Show the extend-length icon at the 3D cursor's projected wall-axis X.", - ) - extend_height: BoolProperty( - name="Extend height to cursor Z", - default=True, - description="Show the extend-height icon at the 3D cursor's Z, on the wall axis.", - ) - rotate: BoolProperty( - name="Rotate 90°", - default=True, - description="Show the rotate-90 icon in the editing icon row (rotates the wall around its Z axis).", - ) - toggle_openings: BoolProperty( - name="Toggle Openings", - default=True, - description="Show the toggle-openings icon next to the pen (toggles opening fill visibility in the viewport).", - ) - - if TYPE_CHECKING: - length: bool - height: bool - height_end: bool - x_angle: bool - cycle: bool - scissors: bool - extend: bool - extend_height: bool - rotate: bool - toggle_openings: bool - - class GizmoPreferences(bpy.types.PropertyGroup): - """Property group for all gizmo visibility settings.""" + """Aggregator for parametric gizmo visibility settings. One flat bool per + parametric feature; controls whether that feature's gizmo group polls + visible in the viewport.""" draw_gizmos_in_3d_viewport: BoolProperty( name="Draw Gizmos In 3D Viewport", default=True, description="Show interactive gizmos in the 3D viewport for parametric elements", ) - door: bpy.props.PointerProperty(type=GizmoPreferencesDoor) - window: bpy.props.PointerProperty(type=GizmoPreferencesWindow) - stair: bpy.props.PointerProperty(type=GizmoPreferencesStair) - wall: bpy.props.PointerProperty(type=GizmoPreferencesWall) + door: BoolProperty(name="Door", default=True) + window: BoolProperty(name="Window", default=True) + stair: BoolProperty(name="Stair", default=True) + railing: BoolProperty(name="Railing", default=True) + roof: BoolProperty(name="Roof", default=True) + array: BoolProperty(name="Array", default=True) + wall: BoolProperty(name="Wall", default=True) if TYPE_CHECKING: draw_gizmos_in_3d_viewport: bool - door: GizmoPreferencesDoor - window: GizmoPreferencesWindow - stair: GizmoPreferencesStair - wall: GizmoPreferencesWall + door: bool + window: bool + stair: bool + railing: bool + roof: bool + array: bool + wall: bool class DocPreferences(bpy.types.PropertyGroup): @@ -913,61 +744,13 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): ) def draw_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + """Render one enabled-toggle per parametric feature.""" layout.label(text="Toggle visibility of gizmos in editing mode") box = layout.box() - bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters) - bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters) - bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters) - bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Wall", self.draw_wall_gizmo_parameters) - - def _draw_parametric_gizmo_parameters( - self, - layout: bpy.types.UILayout, - gizmo_pg: bpy.types.PropertyGroup, - dimension_gizmo_class: type, - special_gizmo_names: frozenset[str] = frozenset(), - ) -> None: - """Draw the per-element gizmo visibility toggles. Surfaces every annotation - on ``gizmo_pg`` that either maps to one of ``dimension_gizmo_class``'s - dimension gizmos or is named in ``special_gizmo_names`` (non-dimension icons - like baseline cycle, scissors, rotate, …).""" - visible_names = {p.attr_name for p in dimension_gizmo_class.dimension_gizmo_props} | special_gizmo_names - try: - annotations = gizmo_pg.__annotations__ - except AttributeError: - annotations = type(gizmo_pg).__annotations__ - for prop in annotations: - if prop in visible_names: - layout.prop(gizmo_pg, prop) - - def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: - from bonsai.bim.module.model.door import GizmoDoorEdition - - self._draw_parametric_gizmo_parameters( - layout, self.gizmos.door, GizmoDoorEdition, frozenset({"swing_arc", "flip_arc"}) - ) - - def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: - from bonsai.bim.module.model.window import GizmoWindowEdition - - self._draw_parametric_gizmo_parameters(layout, self.gizmos.window, GizmoWindowEdition) - - def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: - from bonsai.bim.module.model.stair import GizmoStairEdition - - self._draw_parametric_gizmo_parameters( - layout, self.gizmos.stair, GizmoStairEdition, frozenset({"lock", "plus", "minus", "cycle"}) - ) - - def draw_wall_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: - from bonsai.bim.module.model.wall import GizmoWallEdition - - self._draw_parametric_gizmo_parameters( - layout, - self.gizmos.wall, - GizmoWallEdition, - frozenset({"cycle", "scissors", "extend", "extend_height", "rotate", "toggle_openings"}), - ) + annotations = type(self.gizmos).__annotations__ + for feature in tool.Parametric.EDIT_TYPES: + if feature.name in annotations: + box.prop(self.gizmos, feature.name) def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "occurrence_name_style") diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py index c807a123ce..e9959ca9bc 100644 --- a/src/bonsai/bonsai/tool/parametric.py +++ b/src/bonsai/bonsai/tool/parametric.py @@ -383,29 +383,6 @@ class Parametric(bonsai.core.tool.Parametric): if hasattr(bpy.types.Object, feature.props_attr): delattr(bpy.types.Object, feature.props_attr) - @classmethod - def iter_gizmo_preference_classes(cls, ui_module) -> list[type]: - """``GizmoPreferences`` classes that exist on ``ui_module`` for - every registry entry, plus the shared ``GizmoPreferencesFeature`` if - present. Order matches ``EDIT_TYPES``. Used by ``bim/__init__.py`` to - inject the per-type ``GizmoPreferences`` classes at the correct - point — before ``ui.GizmoPreferences``, which references them via - ``PointerProperty``.""" - # FIXME(PR5): drop the per-feature loop once PR4 consolidates - # bim/ui.py to use a single shared GizmoPreferencesFeature class - # and rewrites GizmoPreferences accordingly. The shared-class - # branch is the forward-compat path; the per-feature loop keeps - # v0.8.0's bim/ui.py working until then. - out: list[type] = [] - for feature in cls.EDIT_TYPES: - gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None) - if gpref is not None: - out.append(gpref) - shared = getattr(ui_module, "GizmoPreferencesFeature", None) - if shared is not None: - out.append(shared) - return out - # --- Feature-kind predicates ------------------------------------------------ # One predicate per registered parametric type. Each is total: accepts any # IFC entity (or None), returns a bool, never raises. Predicates live with diff --git a/src/bonsai/test/bim/module/model/test_wall_preview_mesh.py b/src/bonsai/test/bim/module/model/test_wall_preview_mesh.py new file mode 100644 index 0000000000..405e545475 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_preview_mesh.py @@ -0,0 +1,79 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Pins the outward-normals invariant of the parametric-wall draft preview mesh. + +``regenerate_wall_mesh_from_props`` rebuilds ``obj.data`` as a fresh bmesh +box from ``BIMWallProperties`` every time a gizmo handle moves. The hand +authored face windings carry no guarantee of outward orientation, so the +function must normalise face windings before writing the mesh back — +otherwise the viewport renders the draft with inverted shading and +back-face culling hides faces the user expects to see.""" + +import types +from unittest.mock import patch + +import bpy +import pytest +from mathutils import Vector + +pytestmark = pytest.mark.wall + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def test_regenerate_wall_mesh_from_props_outward_normals(): + """Every face of the preview box must have its normal pointing away + from the box centroid — the contract every other preview-mesh builder + in ``bim/module/model`` (door / window / roof / railing) holds.""" + from bonsai.bim.module.model.wall import regenerate_wall_mesh_from_props + + mesh = bpy.data.meshes.new("preview_mesh") + obj = bpy.data.objects.new("preview_wall", mesh) + fake_props = types.SimpleNamespace( + length=2.0, + height=3.0, + thickness=0.2, + offset=0.0, + x_angle=0.0, + anchor_x=0.0, + mesh_dirty=False, + ) + + try: + with patch("bonsai.tool.Model.get_wall_props", return_value=fake_props): + regenerate_wall_mesh_from_props(obj) + + assert len(mesh.polygons) == 6, f"expected 6 faces, got {len(mesh.polygons)}" + centroid = sum((v.co for v in mesh.vertices), Vector()) / len(mesh.vertices) + for face in mesh.polygons: + outward = (face.center - centroid).normalized() + dot = face.normal.dot(outward) + assert dot > 0.5, ( + f"face {face.index} normal {tuple(face.normal)} points inward " + f"(outward direction {tuple(outward)}, dot={dot:.3f})" + ) + finally: + bpy.data.objects.remove(obj) + bpy.data.meshes.remove(mesh) diff --git a/src/bonsai/test/bim/test_parametric_registry.py b/src/bonsai/test/bim/test_parametric_registry.py index ec4383dccb..4df71438ca 100644 --- a/src/bonsai/test/bim/test_parametric_registry.py +++ b/src/bonsai/test/bim/test_parametric_registry.py @@ -22,9 +22,10 @@ The registry is the single source of truth for which parametric element types exist. Every consumer (auto-commit on save, finish/cancel chains, the -``PointerProperty`` attachment, the ``GizmoPreferences`` registration) derives -identifiers from each entry's short ``name`` token. Forget any downstream -registration and the silent-desync the framework exists to prevent will ship. +``PointerProperty`` attachment, the ``GizmoPreferences`` per-feature toggle) +derives identifiers from each entry's short ``name`` token. Forget any +downstream registration and the silent-desync the framework exists to prevent +will ship. These tests pin the registry-to-runtime contract: for every entry the operator ``bl_idname``s resolve to registered ``bpy.ops.bim.*`` callables, the @@ -122,19 +123,14 @@ def test_every_predicate_does_not_raise_on_non_matching_element(registry): ) -def test_gizmo_preferences_attached_when_class_exists(registry): - """For every registry entry whose ``GizmoPreferences`` class exists in - ``bonsai.bim.ui``, the matching sub-PointerProperty must be declared on - ``ui.GizmoPreferences`` under the registry entry's ``name`` token. - - Catches the silent-skip behaviour of the registry-driven gizmo-prefs - discovery: a typo in the class name or a dropped registration would - otherwise produce a missing sub-panel at runtime with no error. - Entries without a ``GizmoPreferences`` class are allowed — not - every parametric type ships gizmo prefs. +def test_gizmo_preferences_field_per_registry_entry(registry): + """Every registry entry must have a matching ``: BoolProperty`` field + on ``ui.GizmoPreferences`` so the addon-preferences UI auto-renders a + toggle for it and ``BaseParametricGizmoGroup.poll`` can gate the whole + gizmo group on ``prefs.gizmos.``. Checks ``__annotations__`` rather than ``hasattr`` because Blender's - PropertyGroup syntax (``field: bpy.props.PointerProperty(...)``) is an + PropertyGroup syntax (``field: bpy.props.BoolProperty(...)``) is an annotation-only assignment — the attribute only materialises on the class after Blender's metaclass installs the bpy_struct descriptor, which depends on registration timing. Reading ``__annotations__`` @@ -142,16 +138,9 @@ def test_gizmo_preferences_attached_when_class_exists(registry): from bonsai.bim import ui annotations = getattr(ui.GizmoPreferences, "__annotations__", {}) - missing = [] - for feature in registry: - prefs_class_name = f"GizmoPreferences{feature.name.capitalize()}" - if not hasattr(ui, prefs_class_name): - continue - if feature.name not in annotations: - missing.append((feature.name, prefs_class_name)) + missing = [feature.name for feature in registry if feature.name not in annotations] assert not missing, ( - f"ui.GizmoPreferences missing sub-PointerProperty field(s) for: {missing} — " - f"each registered ``GizmoPreferences`` class must have a matching " - f"``: PointerProperty(type=GizmoPreferences)`` field on " - f"``ui.GizmoPreferences``" + f"ui.GizmoPreferences missing BoolProperty field(s) for: {missing} — " + f"each registry entry must have a matching ``: BoolProperty(...)`` " + f"field on ``ui.GizmoPreferences`` so the preferences UI surfaces a toggle" )