Drop per-gizmo preferences + fix dynamic-wall face normals + DRY colors

Three related cleanups in one pass:

* **Per-gizmo preferences removed.** The ``visibility_pref`` field on
  IconSlot, the ``prefs.gizmos.<feature>.<icon>`` PropertyGroups, and
  the dispatcher that surfaced them in the addon preferences UI are
  all gone. ``update_gizmo_visibility`` loses its ``pref_enabled``
  parameter — visibility is now driven purely by editing state and
  modal gating. bim/ui.py drops ~257 lines of dead PropertyGroup
  definitions; bim/__init__.py and tool/parametric.py shed their
  matching wiring; door / wall slot declarations stop referencing
  the now-nonexistent prefs.

* **Dynamic-wall face normals fixed.** ``regenerate_wall_mesh_from_props``
  in wall.py now calls ``bmesh.ops.recalc_face_normals`` before writing
  the mesh. Without it, walls regenerated from the parametric edit
  draft could ship with inward-facing normals on some faces, which
  rendered as visual holes under any backface-cull or normal-aware
  shading. ``test/bim/module/model/test_wall_preview_mesh.py`` pins
  the invariant (every face's normal points away from the wall centre).

* **Color constants DRY.** ``COLOR_RED`` / ``COLOR_GREEN`` /
  ``COLOR_BLUE`` / ``COLOR_NEUTRAL`` now live at module scope in
  gizmos.py; the BaseParametricGizmoGroup class attributes alias the
  same tuples so ``self.COLOR_GREEN`` keeps working. IconSlot
  declarations in stair.py (plus / minus) and array.py (count_minus /
  count_plus / delete) now reference the named constants instead of
  duplicating the RGB tuples inline.

Verified: headless smoke green at 1267 BIM_OT_ classes,
test_parametric_registry.py 8/8, wall lane 31/31 (includes the new
preview-mesh test). ruff + black clean on the touched files.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-01 18:32:54 +02:00
committed by Thomas Krijnen
parent 782f25bd31
commit fbfbe93550
10 changed files with 175 additions and 401 deletions
-16
View File
@@ -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<X>`` 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<Name>`` 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
+28 -56
View File
@@ -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.<feature>``; 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
+9 -4
View File
@@ -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,
+2 -9
View File
@@ -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
+12 -16
View File
@@ -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
+9 -13
View File
@@ -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)
+22 -239
View File
@@ -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")
-23
View File
@@ -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<Name>`` 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<X>`` 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
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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)
+14 -25
View File
@@ -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<X>`` 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<Name>`` 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<Name>`` 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 ``<name>: 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.<name>``.
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<Name>`` class must have a matching "
f"``<name>: PointerProperty(type=GizmoPreferences<Name>)`` field on "
f"``ui.GizmoPreferences``"
f"ui.GizmoPreferences missing BoolProperty field(s) for: {missing}"
f"each registry entry must have a matching ``<name>: BoolProperty(...)`` "
f"field on ``ui.GizmoPreferences`` so the preferences UI surfaces a toggle"
)