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"
)