diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index b3d2ac4fda..e5aa3173f1 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -87,7 +87,18 @@ camera is viewing from the positive or negative side of each axis. """ __all__ = [ + "GizmoColor", + "GizmoAxis", + "TextAlignment", + "CoordinateSpace", + "ModalState", "DimensionGizmoConfig", + "DimensionDrawConfig", + "ViewDirection", + "GizmoModalContext", + "get_modal_context", + "get_validated_modal_context", + "ParametricProps", "NumericInputState", "GPUStateScope", "set_snap_point", @@ -114,13 +125,15 @@ __all__ = [ "GizmoCone", "GizmoDimension", "DimensionRenderer", + "CycleTypeMixin", "BaseParametricGizmoGroup", "UglyDotGizmo", "ExtrusionGuidesGizmo", "ExtrusionWidget", ] -from typing import Any, Callable, Iterator +from typing import Any, Callable, Iterator, Literal, Protocol, runtime_checkable, get_args +from enum import Enum import blf import bpy @@ -177,7 +190,116 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL -@dataclass +class GizmoColor(Enum): + """Color identifiers for dimension gizmos. + + Maps axis directions to colors following BIM/CAD conventions: + - RED: X-axis (width) + - GREEN: Y-axis (depth) + - BLUE: Z-axis (height) + + Use GizmoColor.from_axis() to auto-derive color from axis direction. + """ + + RED = "RED" + GREEN = "GREEN" + BLUE = "BLUE" + + @classmethod + def from_axis(cls, axis: tuple[int, int, int]) -> "GizmoColor": + """Derive color from axis direction. + + Args: + axis: Direction tuple (x, y, z) with at least one non-zero component. + + Returns: + GizmoColor based on the first non-zero axis component. + """ + if axis[0] != 0: + return cls.RED + elif axis[1] != 0: + return cls.GREEN + return cls.BLUE + + +class TextAlignment(Enum): + """Text alignment options for dimension gizmo labels. + + Controls where the dimension value text is positioned along the dimension line. + """ + + START = "start" # Align text to the start of the dimension line + CENTER = "center" # Center text on the dimension line (default) + END = "end" # Align text to the end of the dimension line + + +# Type alias for gizmo axis direction tuples +# Each component must be -1, 0, or 1 to indicate direction along that axis +GizmoAxis = tuple[Literal[-1, 0, 1], Literal[-1, 0, 1], Literal[-1, 0, 1]] + + +class CoordinateSpace(Enum): + """Coordinate space identifiers for gizmo positioning. + + Clarifies which space a position or direction is expressed in: + - LOCAL: Object-local coordinates (relative to element origin) + - WORLD: World/scene coordinates (absolute position) + - SCREEN: 2D screen-space coordinates (pixels) + + Usage: + # Document coordinate space in function signatures + def get_position(self, space: CoordinateSpace = CoordinateSpace.LOCAL) -> Vector: + ... + + # Or use as documentation in comments + local_pos = Vector((0, 0, 1)) # CoordinateSpace.LOCAL + world_pos = matrix_world @ local_pos # CoordinateSpace.WORLD + """ + + LOCAL = "local" # Object-local space (relative to element matrix_world) + WORLD = "world" # World/scene space (absolute coordinates) + SCREEN = "screen" # 2D screen space (pixel coordinates) + + +class ModalState(Enum): + """State machine states for modal gizmo operations. + + Used to track the current interaction mode during gizmo manipulation. + Helps organize modal operator logic and determine valid state transitions. + + States: + IDLE: No interaction active, waiting for user input + DRAGGING: User is dragging the gizmo with the mouse + KEYBOARD_INPUT: User is typing a numeric value + SNAPPING: Dragging with snap enabled (Ctrl held) + PRECISION: Dragging with precision mode (Shift held) + + State transitions: + IDLE -> DRAGGING: Mouse press on gizmo + IDLE -> KEYBOARD_INPUT: Numeric key press + DRAGGING -> SNAPPING: Ctrl pressed during drag + DRAGGING -> PRECISION: Shift pressed during drag + SNAPPING -> DRAGGING: Ctrl released + PRECISION -> DRAGGING: Shift released + * -> IDLE: Mouse release, Enter, Escape + """ + + IDLE = "idle" + DRAGGING = "dragging" + KEYBOARD_INPUT = "keyboard_input" + SNAPPING = "snapping" + PRECISION = "precision" + + def is_active(self) -> bool: + """Check if this state represents an active interaction.""" + return self != ModalState.IDLE + + def allows_keyboard_input(self) -> bool: + """Check if this state allows transitioning to keyboard input.""" + return self in (ModalState.IDLE, ModalState.DRAGGING) + + +@dataclass(slots=True) class GizmoModalContext: """Typed context for modal gizmo operations. @@ -224,6 +346,38 @@ class GizmoModalContext: _gizmo_modal_context = GizmoModalContext() +def get_modal_context() -> GizmoModalContext: + """Get the global modal gizmo context. + + Provides access to the module-level context without exposing the private variable. + Use this when reading context values that may be None. + + Returns: + The global GizmoModalContext instance. + """ + return _gizmo_modal_context + + +def get_validated_modal_context() -> GizmoModalContext: + """Get the modal context, validating that essential fields are set. + + Use this when the context is expected to be fully initialized (e.g., during + modal operator execution). Raises RuntimeError if the context is incomplete. + + Returns: + The global GizmoModalContext instance with essential fields validated. + + Raises: + RuntimeError: If active_gizmo or gizmo_group is None. + """ + ctx = _gizmo_modal_context + if ctx.active_gizmo is None: + raise RuntimeError("Modal context not initialized: active_gizmo is None") + if ctx.gizmo_group is None: + raise RuntimeError("Modal context not initialized: gizmo_group is None") + return ctx + + class GPUStateScope: """Context manager for saving and restoring GPU state. @@ -352,7 +506,7 @@ class DimensionTextRenderer: value: float, color: tuple[float, float, float], offset_sign: int = 1, - alignment: str = "center", + alignment: TextAlignment | str = TextAlignment.CENTER, ) -> None: """Draw formatted dimension value text at the given screen position. @@ -363,9 +517,16 @@ class DimensionTextRenderer: value: Dimension value to format and display color: Text color (r, g, b) offset_sign: 1 for above/right, -1 for below/left - alignment: "center" or "start" + alignment: TextAlignment enum value """ - text = tool.Unit.format_distance(value) + # Normalize string to enum for comparison + if isinstance(alignment, str): + alignment = TextAlignment(alignment) + + is_negative = value < 0 + text = tool.Unit.format_distance(abs(value)) + if is_negative: + text = "-" + text font_id = 0 font_size = tool.Blender.scale_font_size(self.VALUE_FONT_SIZE) @@ -376,7 +537,7 @@ class DimensionTextRenderer: text_width, text_height = blf.dimensions(font_id, text) offset_distance = (text_height + 4) * offset_sign - if alignment == "start": + if alignment == TextAlignment.START: text_x = screen_pos[0] + perpendicular[0] * offset_distance text_y = screen_pos[1] - text_height / 2 + perpendicular[1] * offset_distance else: @@ -453,6 +614,118 @@ class DimensionTextRenderer: batch.draw(shader) +@dataclass(slots=True, frozen=True) +class DimensionDrawConfig: + """Immutable configuration for drawing a dimension line. + + Groups the many parameters needed by DimensionRenderer.draw() into a + single configuration object, improving readability and maintainability. + + Attributes: + start_world: World-space start position + end_world: World-space end position + axis_world: Normalized axis direction in world space + dimension_length: Length of the dimension (for drawing the line) + color: Base color (r, g, b) + alpha: Base alpha (0.0 to 1.0) + is_highlight: Whether gizmo is highlighted/hovered + highlight_color: Highlight color (r, g, b) + highlight_alpha: Highlight alpha + show_start_arrow: Whether to show arrow at start + show_end_arrow: Whether to show arrow at end + show_extension_lines: Whether to show extension lines + text_offset_sign: 1 for above/right, -1 for below/left + text_alignment: TextAlignment value for text positioning along line + prop_name: Property name for tooltip (shown when highlighted) + display_value: Value to display as text (can be negative); uses dimension_length if None + """ + + start_world: Vector + end_world: Vector + axis_world: Vector + dimension_length: float + color: tuple[float, float, float] = (1.0, 1.0, 1.0) + alpha: float = 1.0 + is_highlight: bool = False + highlight_color: tuple[float, float, float] = (1.0, 1.0, 0.5) + highlight_alpha: float = 1.0 + show_start_arrow: bool = False + show_end_arrow: bool = True + show_extension_lines: bool = True + text_offset_sign: Literal[-1, 1] = 1 + text_alignment: TextAlignment = TextAlignment.CENTER + prop_name: str | None = None + display_value: float | None = None + + +@dataclass(slots=True, frozen=True) +class ViewDirection: + """Immutable representation of camera view direction relative to an element's local space. + + Provides a cleaner API than tuple unpacking for view-dependent gizmo positioning. + + With: + view = self.get_view_direction(context, mw) + if view.from_back: ... + + Attributes: + from_negative_y: True if camera is on the -Y side (viewing from "back") + from_negative_x: True if camera is on the -X side (viewing from "left") + + Properties: + from_back: Alias for from_negative_y (more intuitive for doors/windows) + from_front: Inverse of from_back + from_left: Alias for from_negative_x + from_right: Inverse of from_left + """ + + from_negative_y: bool = False + from_negative_x: bool = False + + @property + def from_back(self) -> bool: + """True if viewing from the back (-Y) side of the element.""" + return self.from_negative_y + + @property + def from_front(self) -> bool: + """True if viewing from the front (+Y) side of the element.""" + return not self.from_negative_y + + @property + def from_left(self) -> bool: + """True if viewing from the left (-X) side of the element.""" + return self.from_negative_x + + @property + def from_right(self) -> bool: + """True if viewing from the right (+X) side of the element.""" + return not self.from_negative_x + + @classmethod + def from_context(cls, context: bpy.types.Context, world_matrix: Matrix) -> "ViewDirection": + """Create ViewDirection from Blender context and object world matrix. + + Args: + context: Blender context with region_data + world_matrix: Object's world transformation matrix + + Returns: + ViewDirection instance, defaults to (False, False) if region data unavailable. + """ + rv3d = context.region_data + if not rv3d: + return cls() + + view_direction = Vector(rv3d.view_rotation @ Vector((0, 0, -1))) + local_view_dir = world_matrix.inverted().to_3x3() @ view_direction + + return cls( + from_negative_y=local_view_dir.y < 0, + from_negative_x=local_view_dir.x < 0, + ) + + class DimensionRenderer: """Handles rendering of dimension line graphics. @@ -462,6 +735,9 @@ class DimensionRenderer: Usage: renderer = DimensionRenderer.get_instance() + config = DimensionDrawConfig(start_world, end_world, axis_world, length, color) + renderer.draw(context, config) + # Or use legacy method signature: renderer.draw(context, start_world, end_world, ...) """ @@ -512,8 +788,9 @@ class DimensionRenderer: show_end_arrow: bool = True, show_extension_lines: bool = True, text_offset_sign: int = 1, - text_alignment: str = "center", + text_alignment: TextAlignment = TextAlignment.CENTER, prop_name: str | None = None, + display_value: float | None = None, ) -> None: """Draw complete dimension graphics in screen space. @@ -522,7 +799,7 @@ class DimensionRenderer: start_world: World-space start position end_world: World-space end position axis_world: Normalized axis direction in world space - dimension_length: Length of the dimension (for text display) + dimension_length: Length of the dimension (for drawing the line) color: Base color (r, g, b) alpha: Base alpha is_highlight: Whether gizmo is highlighted/hovered @@ -532,12 +809,16 @@ class DimensionRenderer: show_end_arrow: Whether to show arrow at end show_extension_lines: Whether to show extension lines text_offset_sign: 1 for above/right, -1 for below/left - text_alignment: "center" or "start" + text_alignment: TextAlignment enum for text positioning prop_name: Property name for tooltip (shown when highlighted) + display_value: Value to display as text (can be negative); uses dimension_length if None """ if dimension_length < 0: return + # Use display_value for text if provided, otherwise use dimension_length + text_value = display_value if display_value is not None else dimension_length + region = context.region rv3d = context.region_data if not region or not rv3d: @@ -650,7 +931,7 @@ class DimensionRenderer: ) text_color = highlight_color if is_highlight else color DimensionTextRenderer.get_instance().draw_value_text( - context, center_screen, perpendicular, dimension_length, text_color, text_offset_sign, text_alignment + context, center_screen, perpendicular, text_value, text_color, text_offset_sign, text_alignment ) if is_highlight and prop_name: @@ -690,9 +971,9 @@ class DimensionRenderer: return (top, bottom) -@dataclass +@dataclass(slots=True, frozen=True) class SnapCache: - """Unified snap cache with combined KD-tree for vertex snapping.""" + """Immutable snap cache with combined KD-tree for vertex snapping.""" # Combined KD-tree with all world vertices from all objects kd_tree: KDTree @@ -700,7 +981,7 @@ class SnapCache: all_vertices: list[tuple[float, float, float]] -@dataclass +@dataclass(slots=True) class NumericInputState: """State for keyboard numeric input during gizmo operations.""" @@ -756,7 +1037,25 @@ class NumericInputState: self.is_valid = False -@dataclass +@runtime_checkable +class ParametricProps(Protocol): + """Protocol defining the common interface for parametric element properties. + + All parametric property classes (BIMDoorProperties, BIMWindowProperties, + BIMStairProperties, etc.) should implement this interface. This enables + type-safe code in BaseParametricGizmoGroup without importing concrete classes. + + Example: + def update_gizmos(self, props: ParametricProps) -> None: + if props.is_editing: + # Safe to access common properties + ... + """ + + is_editing: bool + + +@dataclass(slots=True) class DimensionGizmoConfig: """Configuration for a dimension gizmo. @@ -814,35 +1113,87 @@ class DimensionGizmoConfig: If None, uses setattr(props, attr_name, value). visibility_condition: Optional function(props) -> bool. If returns False, the gizmo is hidden. Used for conditional gizmos. + matrix_position: Optional function(props) -> Vector for gizmo position. + If provided, eliminates need for get_dimension_matrix_{attr_name} method. + The returned Vector is the local-space position where the gizmo origin + will be placed. Combined with axis to create the full transformation matrix. """ attr_name: str - axis: tuple[int, int, int] - color: str | None = None + axis: GizmoAxis + color: GizmoColor | str | None = None # GizmoColor enum, string ("RED"/"GREEN"/"BLUE"), or None for auto prop_name: str | None = None min_value: float = 0.0 invert_delta: bool = False delta_scale: float = 1.0 - text_offset_sign: int = 1 - text_alignment: str = "center" + text_offset_sign: Literal[-1, 1] = 1 + text_alignment: TextAlignment | str = TextAlignment.CENTER show_start_arrow: bool = False show_end_arrow: bool = True compute_value: Callable[[Any], float] | None = None apply_value: Callable[[Any, float], None] | None = None visibility_condition: Callable[[Any], bool] | None = None + matrix_position: Callable[[Any], "Vector"] | None = None # Optional: function(props) -> Vector position def __post_init__(self): - if self.color is None: - if self.axis[0] != 0: - self.color = "RED" - elif self.axis[1] != 0: - self.color = "GREEN" - else: - self.color = "BLUE" + # Validate attr_name + if not self.attr_name or not isinstance(self.attr_name, str): + raise ValueError("attr_name must be a non-empty string") + # Validate axis + if len(self.axis) != 3: + raise ValueError(f"axis must be a 3-tuple, got {len(self.axis)} elements") + if not any(self.axis): + raise ValueError("axis must have at least one non-zero component") + + # Normalize and validate text_alignment + if isinstance(self.text_alignment, str): + try: + self.text_alignment = TextAlignment(self.text_alignment) + except ValueError: + valid = [e.value for e in TextAlignment] + raise ValueError(f"text_alignment must be one of {valid}, got '{self.text_alignment}'") + elif not isinstance(self.text_alignment, TextAlignment): + raise ValueError(f"text_alignment must be TextAlignment enum or string, got {type(self.text_alignment)}") + + # Validate text_offset_sign + if self.text_offset_sign not in (1, -1): + raise ValueError(f"text_offset_sign must be 1 or -1, got {self.text_offset_sign}") + + # Normalize and validate color + if self.color is None: + # Auto-derive from axis direction + self.color = GizmoColor.from_axis(self.axis) + elif isinstance(self.color, str): + # Convert string to enum + try: + self.color = GizmoColor(self.color) + except ValueError: + raise ValueError(f"color must be 'RED', 'GREEN', or 'BLUE', got '{self.color}'") + elif not isinstance(self.color, GizmoColor): + raise ValueError(f"color must be GizmoColor enum, string, or None, got {type(self.color)}") + + # Auto-derive prop_name from attr_name if not specified if self.prop_name is None: self.prop_name = self.attr_name.replace("_", " ").title() + def __repr__(self) -> str: + """Concise representation showing key configuration values.""" + parts = [f"attr_name={self.attr_name!r}", f"axis={self.axis}"] + if self.color: + parts.append(f"color={self.color.name}") + if self.visibility_condition: + parts.append("visibility_condition=") + if self.matrix_position: + parts.append("matrix_position=") + if self.compute_value: + parts.append("compute_value=") + if self.min_value != 0.0: + parts.append(f"min_value={self.min_value}") + if self.invert_delta: + parts.append("invert_delta=True") + return f"DimensionGizmoConfig({', '.join(parts)})" + class SnapManager: """Manages snap point visualization and mesh snapping with caching.""" @@ -3062,10 +3413,11 @@ class GizmoDimension(GizmoMovable): "_start_mouse_pos", "_has_dragged", "_dimension_length", + "_display_value", # Actual value for display (can be negative) "text_offset_sign", # -1 to offset text below/left, +1 for above/right (default) "show_start_arrow", # Whether to show arrow at start (origin) of dimension "show_end_arrow", # Whether to show arrow at end of dimension - "text_alignment", # "center" (default) or "start" (left-aligned at offset from line) + "text_alignment", # TextAlignment enum: CENTER (default) or START (left-aligned at offset from line) "_original_value", # Original property value before interaction "_click_offset", # Offset from dimension tip to click position (for snap correction) "show_extension_lines", # Whether to show extension lines at dimension endpoints @@ -3095,10 +3447,11 @@ class GizmoDimension(GizmoMovable): def setup(self) -> None: self.custom_shape = self.new_custom_shape("TRIS", self._get_clickable_shape()) self._dimension_length = 1.0 + self._display_value = 1.0 self.text_offset_sign = 1 self.show_start_arrow = False self.show_end_arrow = True - self.text_alignment = "center" + self.text_alignment = TextAlignment.CENTER self.show_extension_lines = True def draw(self, context: bpy.types.Context) -> None: @@ -3125,8 +3478,9 @@ class GizmoDimension(GizmoMovable): show_end_arrow=getattr(self, "show_end_arrow", True), show_extension_lines=getattr(self, "show_extension_lines", True), text_offset_sign=getattr(self, "text_offset_sign", 1), - text_alignment=getattr(self, "text_alignment", "center"), + text_alignment=getattr(self, "text_alignment", TextAlignment.CENTER), prop_name=getattr(self, "prop_name", None), + display_value=getattr(self, "_display_value", self._dimension_length), ) def _calculate_screen_endpoints( @@ -3243,8 +3597,10 @@ class GizmoDimension(GizmoMovable): # Validate input: reject NaN, Inf, and non-numeric values if not isinstance(length, (int, float)) or math.isnan(length) or math.isinf(length): length = 0.0 - # Clamp to valid range (0 to 10000 meters is reasonable for BIM) - self._dimension_length = max(0.0, min(length, 10000.0)) + # Store the actual value for display (can be negative) + self._display_value = max(-10000.0, min(length, 10000.0)) + # Clamp to valid range (0 to 10000 meters is reasonable for BIM) for drawing + self._dimension_length = max(0.0, min(abs(length), 10000.0)) def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set: """Initialize dimension gizmo interaction with click-position tracking. @@ -3498,6 +3854,54 @@ class GizmoDimension(GizmoMovable): clear_snap_cache() +class CycleTypeMixin: + """Mixin for operators that cycle through type literals. + + Subclasses must define: + element_checker: Class method name on tool.Blender.Modifier (e.g., "is_door") + props_getter: Method name on tool.Model (e.g., "get_door_props") + type_literal: The type literal from tool.Model (e.g., tool.Model.DoorType) + type_attr: Attribute name on props for the type (e.g., "door_type") + + Optional: + skip_element_check: If True, skip the element type validation (default False) + """ + + element_checker: str + props_getter: str + type_literal: type + type_attr: str + skip_element_check: bool = False + + reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"}) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: + """Set reverse direction based on Shift key.""" + self.reverse = event.shift + return self.execute(context) + + def _cycle_type(self, context: bpy.types.Context) -> set[str]: + """Common type cycling logic. Call from execute() or _execute().""" + obj = context.active_object + if not obj: + return {"CANCELLED"} + + if not self.skip_element_check: + element = tool.Ifc.get_entity(obj) + checker = getattr(tool.Blender.Modifier, self.element_checker) + if not element or not checker(element): + return {"CANCELLED"} + + props = getattr(tool.Model, self.props_getter)(obj) + types = get_args(self.type_literal) + current = getattr(props, self.type_attr) + idx = types.index(current) if current in types else 0 + direction = -1 if self.reverse else 1 + setattr(props, self.type_attr, types[(idx + direction) % len(types)]) + + return {"FINISHED"} + + class BaseParametricGizmoGroup: """Base mixin for parametric element gizmo groups (doors, windows, stairs, etc.). @@ -3545,6 +3949,31 @@ class BaseParametricGizmoGroup: - Dimension gizmos: Positioned with GIZMO_OFFSET from geometry edges - Icon gizmos: Positioned above element using ICON_Z_OFFSET, laid out horizontally + + Color Convention + ================ + + Gizmo colors follow Blender's axis convention: + - RED: X-axis dimensions (width) + - GREEN: Y-axis dimensions (depth) + - BLUE: Z-axis dimensions (height) + + View Direction API + ================== + + Use ``ViewDirection.from_context(context, mw)`` to determine camera position:: + + view = ViewDirection.from_context(context, obj.matrix_world) + if view.from_back: # Camera behind element (interior for doors) + y_pos = props.depth + if view.from_left: # Camera on left side + x_pos = -offset + + Negative Value Handling + ======================= + + Some dimensions support negative values (e.g., lining_offset). When negative, + the gizmo is flipped 180° using FLIP_MATRIX so the arrow points opposite. """ # === Gizmo Colors === @@ -3556,6 +3985,11 @@ class BaseParametricGizmoGroup: # === Dimension Gizmo Layout (meters) === ARROW_SCALE = 0.25 # Scale factor for arrow gizmos GIZMO_OFFSET = 0.15 # Distance from geometry edge to dimension line + GIZMO_STACK_OFFSET = 0.1 # Offset increment for stacking multiple gizmos to avoid overlap + GIZMO_CLAMP_MAX = 10000.0 # Maximum value for dimension clamping (meters) + + # Pre-computed flip matrix for negative value handling (180° rotation around Z) + FLIP_MATRIX = Matrix.Rotation(math.pi, 4, 'Z') # === Icon Gizmo Layout (meters) === # Icons are positioned in a horizontal row above the element: @@ -3575,10 +4009,17 @@ class BaseParametricGizmoGroup: cycle_type_operator: str = "" @classmethod - def get_color_from_name(cls, color_name: str) -> tuple[float, float, float]: - """Get color tuple from color name string.""" - colors = {"RED": cls.COLOR_RED, "GREEN": cls.COLOR_GREEN, "BLUE": cls.COLOR_BLUE} - return colors.get(color_name.upper(), cls.COLOR_RED) + def get_color_from_name(cls, color: GizmoColor | str) -> tuple[float, float, float]: + """Get color tuple from GizmoColor enum or color name string.""" + colors = { + GizmoColor.RED: cls.COLOR_RED, + GizmoColor.GREEN: cls.COLOR_GREEN, + GizmoColor.BLUE: cls.COLOR_BLUE, + } + if isinstance(color, GizmoColor): + return colors.get(color, cls.COLOR_RED) + # Handle legacy string input + return colors.get(GizmoColor(color.upper()), cls.COLOR_RED) @classmethod def get_arrow_color_from_axis(cls, axis: tuple[int, int, int]) -> tuple[float, float, float]: @@ -3604,6 +4045,8 @@ class BaseParametricGizmoGroup: - viewing_from_negative_x: True if camera is on the -X side of the element Returns (False, False) if region data is unavailable. + + Note: Consider using ViewDirection.from_context() for a cleaner API. """ rv3d = context.region_data if not rv3d: @@ -3617,6 +4060,44 @@ class BaseParametricGizmoGroup: return viewing_from_negative_y, viewing_from_negative_x + def get_view_direction(self, context: bpy.types.Context, world_matrix: Matrix) -> "ViewDirection": + """Get view direction as a ViewDirection object for cleaner API. + + Example: + view = self.get_view_direction(context, mw) + if view.from_back: + y_pos = props.depth + else: + y_pos = 0 + """ + 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 + """ + if self.is_gizmo_hidden_by_modal(gizmo): + gizmo.hide = True + return False + gizmo.hide = not is_editing or not pref_enabled + return not gizmo.hide + def get_y_position_for_view( self, props, viewing_from_negative_y: bool, width_attr: str = "width", use_offset: bool = False ) -> float: @@ -3639,6 +4120,25 @@ class BaseParametricGizmoGroup: return width + (self.GIZMO_OFFSET if use_offset else 0) return -self.GIZMO_OFFSET if use_offset else 0 + def get_icon_y_for_view(self, props, viewing_from_negative_y: bool) -> float: + """Get Y position for editing icons based on view direction. + + Similar to get_y_position_for_view but always includes offset and + uses the element's furthest Y extent for positioning. + + Args: + props: Element properties object + viewing_from_negative_y: True if viewing from -Y side + + Returns: + Y position for icon row: -GIZMO_OFFSET when viewing from -Y, + width + GIZMO_OFFSET otherwise + """ + width = getattr(props, "width", 0) + if viewing_from_negative_y: + return -self.GIZMO_OFFSET + return width + self.GIZMO_OFFSET + def compose_gizmo_matrix(self, translation: Vector, axis: tuple[int, int, int]) -> Matrix: """Compose a gizmo transformation matrix from translation and axis. @@ -3676,6 +4176,130 @@ class BaseParametricGizmoGroup: return lining_offset + (self.GIZMO_OFFSET if use_offset else 0) return lining_offset - (self.GIZMO_OFFSET if use_offset else 0) + def get_x_positions_for_view( + self, width: float, offset: float, viewing_from_negative_x: bool + ) -> tuple[float, float]: + """Get X positions for height and lining gizmos based on view direction. + + When viewing from -X side, height goes to -X and lining goes to +X. + When viewing from +X side, height goes to +X and lining goes to -X. + + Args: + width: Element width (e.g., overall_width) + offset: Additional offset (e.g., casing_thickness) + viewing_from_negative_x: True if viewing from -X side + + Returns: + Tuple of (x_pos_height, x_pos_lining) + """ + if viewing_from_negative_x: + x_pos_height = -offset - self.GIZMO_OFFSET + x_pos_lining = width + offset + self.GIZMO_OFFSET + else: + x_pos_height = width + offset + self.GIZMO_OFFSET + x_pos_lining = -offset - self.GIZMO_OFFSET + return x_pos_height, x_pos_lining + + def get_dimension_matrix_lining_offset_default(self, props) -> Matrix: + """Default lining offset matrix for door/window elements. + + Position at element width + offset, at Y=0, below the element. + Override in subclass if different positioning is needed. + """ + width = getattr(props, "overall_width", 0) + return self.compose_gizmo_matrix( + Vector((width + self.GIZMO_OFFSET, 0, -self.GIZMO_OFFSET)), (0, 1, 0) + ) + + def get_casing_offset(self, props) -> float: + """Get casing offset for view-dependent dimension positioning. + + Override in door to return casing_thickness when lining_offset is 0. + Default returns 0 (no casing offset). + """ + return 0.0 + + def _update_view_dependent_dimensions( + self, context: bpy.types.Context, mw: Matrix, props + ) -> None: + """Update overall_width, overall_height, and lining_offset based on view direction. + + This base implementation handles the common pattern for door/window gizmos. + Subclasses can override get_casing_offset() to customize behavior. + """ + viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw) + y_pos = self.get_lining_y_position_for_view(props, viewing_from_negative_y) + + self.set_dimension_gizmo_position("overall_width", mw, Vector((0, y_pos, -self.GIZMO_OFFSET)), (1, 0, 0)) + + casing_offset = self.get_casing_offset(props) + x_pos_height, x_pos_lining = self.get_x_positions_for_view( + props.overall_width, casing_offset, viewing_from_negative_x + ) + self.set_dimension_gizmo_position("overall_height", mw, Vector((x_pos_height, y_pos, 0)), (0, 0, 1)) + self.set_dimension_gizmo_position( + "lining_offset", mw, Vector((x_pos_lining, 0, -self.GIZMO_OFFSET)), (0, 1, 0), props.lining_offset + ) + + def create_icon_gizmo( + self, + gizmo_type: str, + color: tuple[float, float, float], + operator: str, + prop_path: str | None = None, + alpha: float = 0.8, + **operator_props, + ) -> bpy.types.Gizmo: + """Create an icon gizmo with common settings. + + Args: + gizmo_type: Blender gizmo type (e.g., "VIEW3D_GT_lock", "VIEW3D_GT_plus") + color: RGB color tuple + operator: Operator ID to trigger (e.g., "bim.toggle_stair_property") + prop_path: Optional property path for lock icons (e.g., "BIMStairProperties.lock") + alpha: Opacity (default 0.8) + **operator_props: Additional operator properties to set + + Returns: + The created gizmo + """ + prefs = tool.Blender.get_addon_preferences() + highlight_color = prefs.decorator_color_selected[:3] + + gz = self.gizmos.new(gizmo_type) + gz.use_draw_scale = False + gz.color = color + gz.color_highlight = highlight_color + gz.alpha = alpha + if prop_path: + gz.prop_path = prop_path + op = gz.target_set_operator(operator) + for key, value in operator_props.items(): + setattr(op, key, value) + return gz + + def create_arc_gizmo( + self, + color: tuple[float, float, float], + operator: str, + prop_path: str | None = None, + alpha: float = 0.5, + **operator_props, + ) -> bpy.types.Gizmo: + """Create an arc gizmo for swing/rotation indicators (e.g., door swing). + + Args: + color: RGB color tuple + operator: Operator ID to trigger (e.g., "bim.toggle_door_swing") + prop_path: Optional property path (e.g., "BIMDoorProperties.door_type") + alpha: Opacity (default 0.5 for arc gizmos) + **operator_props: Additional operator properties to set + + Returns: + The created arc gizmo + """ + return self.create_icon_gizmo("VIEW3D_GT_arc", color, operator, prop_path, alpha, **operator_props) + @classmethod def is_element_type(cls, element) -> bool: raise NotImplementedError("Subclass must implement is_element_type()") @@ -3698,11 +4322,92 @@ class BaseParametricGizmoGroup: return False return True + def setup(self, context: bpy.types.Context) -> None: + """Template method for gizmo setup. + + Subclasses should override setup_element_specific_gizmos() to add + element-specific gizmos (e.g., door swing arcs, stair lock icons). + """ + self.setup_editing_gizmos(context) + self.setup_dimension_gizmos(context) + self.setup_element_specific_gizmos(context) + + def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: + """Override to add element-specific gizmos. + + Called after setup_editing_gizmos and setup_dimension_gizmos. + Examples: door swing arcs, stair lock/plus/minus icons. + """ + pass + + def refresh(self, context: bpy.types.Context) -> None: + """Template method for gizmo refresh. + + Subclasses should override _refresh_element_specific() for element-specific updates + (e.g., door swing arcs, stair lock icons). + """ + if not self.is_setup_complete(): + return + obj = context.active_object + if not obj: + return + + props = self.get_props(obj) + mw = obj.matrix_world + self.update_editing_gizmos(context, mw, props) + self.update_dimension_gizmos(mw, props) + self._refresh_element_specific(context, mw, props) + + def _refresh_element_specific( + self, context: bpy.types.Context, mw: "Matrix", props # noqa: ARG002 + ) -> None: + """Override for element-specific refresh logic. + + Called after update_editing_gizmos and update_dimension_gizmos. + Examples: door swing gizmos, stair lock/tread/plus/minus gizmos. + """ + pass + + # Subclass should define these class attributes for metadata-driven dispatch + # If not defined, subclass must override get_props() and get_gizmo_prefs() + props_getter: str | None = None # e.g., "get_door_props" + gizmo_pref_name: str | None = None # e.g., "door" + def get_props(self, obj: bpy.types.Object) -> Any: - raise NotImplementedError("Subclass must implement get_props()") + """Get properties for the element. + + Subclass can either: + 1. Define class attribute `props_getter` (e.g., "get_door_props") + 2. Override this method directly + """ + if self.props_getter: + return getattr(tool.Model, self.props_getter)(obj) + raise NotImplementedError("Subclass must define props_getter or override get_props()") + @staticmethod + def get_addon_prefs(): + """Get addon preferences (cached accessor).""" + return tool.Blender.get_addon_preferences() + + def get_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]: + """Get default and highlight colors from preferences. + + Returns: + Tuple of (default_color, highlight_color) as RGB tuples. + """ + prefs = self.get_addon_prefs() + return prefs.decorations_colour[:3], prefs.decorator_color_selected[:3] def get_gizmo_prefs(self) -> Any: - raise NotImplementedError("Subclass must implement get_gizmo_prefs()") + """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. @@ -3809,6 +4514,7 @@ class BaseParametricGizmoGroup: mw: Matrix, position: Vector, axis: tuple[int, int, int], + value: float | None = None, ) -> None: """Set a dimension gizmo's position if visible. @@ -3817,9 +4523,34 @@ class BaseParametricGizmoGroup: mw: Object's world matrix position: Local position as Vector or tuple (x, y, z) axis: Direction axis tuple (e.g., (1, 0, 0) for X) + value: Optional value to check for negative flip. If None, no flip is applied. """ if gz := self.get_dimension_gizmo_if_visible(attr_name): - gz.matrix_basis = mw @ self.compose_gizmo_matrix(position, axis) + self._apply_dimension_matrix(gz, mw, self.compose_gizmo_matrix(position, axis), value) + + def _apply_dimension_matrix( + self, + gizmo: bpy.types.Gizmo, + mw: Matrix, + base_matrix: Matrix, + value: float | None = None, + ) -> None: + """Apply matrix to dimension gizmo, flipping for negative values. + + Consolidates negative value handling in one place. For negative values, + the gizmo is rotated 180° around Z so the dimension arrow points in the + opposite direction while keeping the origin at the same position. + + Args: + gizmo: The dimension gizmo to update + mw: Object's world matrix + base_matrix: Local transformation matrix + value: If negative, applies FLIP_MATRIX rotation + """ + if value is not None and value < 0: + gizmo.matrix_basis = mw @ base_matrix @ self.FLIP_MATRIX + else: + gizmo.matrix_basis = mw @ base_matrix def should_hide_dimension_gizmo( self, gizmo: bpy.types.Gizmo, config: "DimensionGizmoConfig", props, gizmo_prefs @@ -3851,39 +4582,56 @@ class BaseParametricGizmoGroup: return True return False + def _setup_icon_gizmo( + self, + gizmo_type: str, + color: tuple[float, float, float], + operator: str, + highlight_color: tuple[float, float, float] | None = None, + alpha: float = 0.8, + ) -> bpy.types.Gizmo: + """Create and configure an icon gizmo with standard settings. + + Reduces boilerplate in setup_editing_gizmos. + + Args: + gizmo_type: Blender gizmo type identifier (e.g., "VIEW3D_GT_pen") + color: RGB color tuple + operator: Operator to invoke on click + highlight_color: Optional highlight color (defaults to prefs selection color) + alpha: Gizmo alpha (default 0.8) + + Returns: + Configured gizmo instance. + """ + if highlight_color is None: + _, highlight_color = self.get_decoration_colors() + + gizmo = self.gizmos.new(gizmo_type) + gizmo.use_draw_scale = False + gizmo.color = color + gizmo.color_highlight = highlight_color + gizmo.alpha = alpha + gizmo.target_set_operator(operator) + return gizmo + def setup_editing_gizmos(self, context: bpy.types.Context) -> None: - prefs = tool.Blender.get_addon_preferences() - default_color = prefs.decorations_colour[:3] - highlight_color = prefs.decorator_color_selected[:3] + default_color, highlight_color = self.get_decoration_colors() - self.pen_gizmo = self.gizmos.new("VIEW3D_GT_pen") - self.pen_gizmo.use_draw_scale = False - self.pen_gizmo.color = default_color - self.pen_gizmo.color_highlight = highlight_color - self.pen_gizmo.alpha = 0.8 - self.pen_gizmo.target_set_operator(self.enable_editing_operator) - - self.validate_gizmo = self.gizmos.new("VIEW3D_GT_validate") - self.validate_gizmo.use_draw_scale = False - self.validate_gizmo.color = self.COLOR_GREEN - self.validate_gizmo.color_highlight = highlight_color - self.validate_gizmo.alpha = 0.8 - self.validate_gizmo.target_set_operator(self.finish_editing_operator) - - self.cancel_gizmo = self.gizmos.new("VIEW3D_GT_cancel") - self.cancel_gizmo.use_draw_scale = False - self.cancel_gizmo.color = self.COLOR_RED - self.cancel_gizmo.color_highlight = highlight_color - self.cancel_gizmo.alpha = 0.8 - self.cancel_gizmo.target_set_operator(self.cancel_editing_operator) + self.pen_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_pen", default_color, self.enable_editing_operator, highlight_color + ) + self.validate_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_validate", self.COLOR_GREEN, self.finish_editing_operator, highlight_color + ) + self.cancel_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_cancel", self.COLOR_RED, self.cancel_editing_operator, highlight_color + ) if self.cycle_type_operator: - self.cycle_gizmo = self.gizmos.new("VIEW3D_GT_cycle") - self.cycle_gizmo.use_draw_scale = False - self.cycle_gizmo.color = default_color - self.cycle_gizmo.color_highlight = highlight_color - self.cycle_gizmo.alpha = 0.8 - self.cycle_gizmo.target_set_operator(self.cycle_type_operator) + self.cycle_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_cycle", default_color, self.cycle_type_operator, highlight_color + ) def _make_dimension_getter(self, config: DimensionGizmoConfig): """Create getter closure for dimension gizmo.""" @@ -3973,32 +4721,61 @@ class BaseParametricGizmoGroup: gizmo.hide = False - matrix_method = getattr(self, f"get_dimension_matrix_{config.attr_name}", None) - base_matrix = matrix_method(props) if matrix_method else Matrix.Identity(4) + # Priority: config.matrix_position > get_dimension_matrix_* method > Identity + if config.matrix_position: + position = config.matrix_position(props) + base_matrix = self.compose_gizmo_matrix(position, config.axis) + else: + matrix_method = getattr(self, f"get_dimension_matrix_{config.attr_name}", None) + base_matrix = matrix_method(props) if matrix_method else Matrix.Identity(4) if config.compute_value: value = config.compute_value(props) else: value = getattr(props, config.attr_name, 0.0) - # Handle negative values by flipping the gizmo direction - if value < 0: - # Flip the X axis (dimension direction) for negative values - flip_matrix = Matrix.Scale(-1, 4, Vector((1, 0, 0))) - gizmo.matrix_basis = mw @ base_matrix @ flip_matrix - gizmo.set_dimension_length(abs(value)) - else: - gizmo.matrix_basis = mw @ base_matrix - gizmo.set_dimension_length(value) + # Use consolidated negative value handling + self._apply_dimension_matrix(gizmo, mw, base_matrix, value) + gizmo.show_start_arrow = config.show_start_arrow + gizmo.show_end_arrow = config.show_end_arrow + gizmo.set_dimension_length(value) + + def get_icon_y_extent(self, props) -> tuple[float, float]: + """Get Y extents for icon positioning based on element geometry. + + Subclasses should override this to return the furthest geometry extents + in the +Y and -Y directions from the element origin. + + Returns: + Tuple of (positive_y_extent, negative_y_extent). + Both values should be positive (absolute distances). + The base implementation returns (0, 0). + + Example for a door: + return (lining_offset + lining_depth + 2*OFFSET, 2*OFFSET) + """ + return (0.0, 0.0) def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: """Get Y offset for icons based on view direction. - Returns negative offset when viewing from -Y side (icons move to -Y), - positive offset when viewing from +Y side (icons move to +Y). - Subclasses can override to customize behavior. + Uses get_icon_y_extent() to determine how far to offset icons based on + the camera viewing direction. Icons are positioned beyond the geometry + on the side the camera is viewing from. + + Subclasses typically only need to override get_icon_y_extent(). """ - return 0.0 + obj = context.active_object + if not obj: + return self.ICON_Y_OFFSET + + props = self.get_props(obj) + positive_extent, negative_extent = self.get_icon_y_extent(props) + + viewing_from_negative_y, _ = self.get_local_view_direction(context, mw) + if viewing_from_negative_y: + return -negative_extent + return positive_extent def update_editing_gizmos(self, context: bpy.types.Context, mw: Matrix, props) -> None: """Update editing icon gizmo positions to billboard toward camera.""" diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index bf2c401f16..47ef5a0343 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -161,8 +161,7 @@ classes = ( stair.FinishEditingStair, stair.EnableEditingStair, stair.RemoveStair, - stair.ToggleStairTotalLengthLock, - stair.ToggleStairCustomTreadLock, + stair.ToggleStairProperty, stair.AdjustStairTreads, stair.SetStairTreads, stair.CycleStairType, diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index 6705b59570..ee9230b016 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -29,7 +29,6 @@ import ifcopenshell.util.representation import ifcopenshell.util.schema import ifcopenshell.util.unit import bonsai.tool as tool -import bonsai.core.geometry import bonsai.core.geometry as core import bonsai.core.root from bonsai.bim.module.model.window import create_bm_window, create_bm_box @@ -41,10 +40,16 @@ from mathutils import Vector, Matrix import json import collections import collections.abc -from typing import get_args +from typing import get_args, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.model.prop import BIMDoorProperties V_ = tool.Blender.V_ +# Shorthand for gizmo offset constants used in DimensionGizmoConfig lambdas +_G = gizmo.BaseParametricGizmoGroup + def update_door_modifier_representation(obj: bpy.types.Object) -> None: props = tool.Model.get_door_props(obj) @@ -142,7 +147,7 @@ def update_door_modifier_representation(obj: bpy.types.Object) -> None: plan_representation = ifcopenshell.api.geometry.add_door_representation(ifc_file, **representation_data) tool.Model.replace_object_ifc_representation(plan_annotation, obj, plan_representation) - bonsai.core.geometry.switch_representation( + core.switch_representation( tool.Ifc, tool.Geometry, obj=obj, @@ -512,7 +517,7 @@ class BIM_OT_add_door(bpy.types.Operator, tool.Ifc.Operator): element = bonsai.core.root.assign_class( tool.Ifc, tool.Collector, tool.Root, obj=obj, ifc_class="IfcDoor", should_add_representation=False ) - bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + core.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) if tool.Ifc.get_schema() != "IFC2X3": element.PredefinedType = "DOOR" @@ -556,7 +561,7 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator): ) update_door_modifier_representation(obj) - def _execute(self, context: bpy.types.Context) -> set[str]: + def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002 for obj in tool.Blender.get_selected_objects(): if not tool.Blender.Modifier.is_eligible_for_door_modifier(obj): continue @@ -584,7 +589,7 @@ class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator): props.set_props_kwargs_from_ifc_data(data) body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") - bonsai.core.geometry.switch_representation( + core.switch_representation( tool.Ifc, tool.Geometry, obj=obj, @@ -593,7 +598,7 @@ class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator): props.is_editing = False - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002 for obj in tool.Blender.get_selected_objects(): self.cancel_editing_door_on_object(obj) return {"FINISHED"} @@ -605,7 +610,7 @@ class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Apply changes and finish editing door parameters" bl_options = {"REGISTER", "UNDO"} - def finish_editing_door_on_object(self, obj): + def finish_editing_door_on_object(self, obj: bpy.types.Object) -> None: element = tool.Ifc.get_entity(obj) assert element if not tool.Blender.Modifier.is_door(element): @@ -630,7 +635,7 @@ class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator): door_data = tool.Ifc.get().createIfcText(json.dumps(door_data, default=list)) ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": door_data}) - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002 for obj in tool.Blender.get_selected_objects(): self.finish_editing_door_on_object(obj) return {"FINISHED"} @@ -642,7 +647,7 @@ class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Enter edit mode to modify door parameters interactively" bl_options = {"REGISTER", "UNDO"} - def edit_door_on_obj(self, obj): + def edit_door_on_obj(self, obj: bpy.types.Object) -> None: element = tool.Ifc.get_entity(obj) assert element if not tool.Blender.Modifier.is_door(element): @@ -657,7 +662,7 @@ class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator): props.set_props_kwargs_from_ifc_data(data) props.is_editing = True - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002 for obj in tool.Blender.get_selected_objects(): self.edit_door_on_obj(obj) return {"FINISHED"} @@ -668,7 +673,7 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Remove Door on Selected Objects" bl_options = {"REGISTER", "UNDO"} - def remove_door_on_object(self, obj): + def remove_door_on_object(self, obj: bpy.types.Object) -> None: element = tool.Ifc.get_entity(obj) assert element if not tool.Blender.Modifier.is_door(element): @@ -679,7 +684,7 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator): pset = tool.Pset.get_element_pset(element, "BBIM_Door") ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset) - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002 for obj in tool.Blender.get_selected_objects(): self.remove_door_on_object(obj) return {"FINISHED"} @@ -702,7 +707,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator): name="Skip Direction Change", default=False, options={"HIDDEN", "SKIP_SAVE"} ) - def invoke(self, context, event): + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: self.skip_direction_change = event.shift return self.execute(context) @@ -719,7 +724,7 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator): return True return False - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002 obj = tool.Blender.get_active_object() if not obj: return {"CANCELLED"} @@ -742,36 +747,20 @@ class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator): +class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin): """Cycle through available door types. Shift+click to cycle in reverse.""" bl_idname = "bim.cycle_door_type" bl_label = "Cycle Door Type" bl_options = {"REGISTER", "UNDO"} - reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"}) + element_checker = "is_door" + props_getter = "get_door_props" + type_literal = tool.Model.DoorType + type_attr = "door_type" - def invoke(self, context, event): - self.reverse = event.shift - return self.execute(context) - - def _execute(self, context): - obj = tool.Blender.get_active_object() - if not obj: - return {"CANCELLED"} - - element = tool.Ifc.get_entity(obj) - if not element or not tool.Blender.Modifier.is_door(element): - return {"CANCELLED"} - - props = tool.Model.get_door_props(obj) - door_types = get_args(tool.Model.DoorType) - current_index = door_types.index(props.door_type) if props.door_type in door_types else 0 - direction = -1 if self.reverse else 1 - next_index = (current_index + direction) % len(door_types) - props.door_type = door_types[next_index] - - return {"FINISHED"} + def _execute(self, context: bpy.types.Context) -> set[str]: + return self._cycle_type(context) class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): @@ -786,56 +775,110 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): cancel_editing_operator = "bim.cancel_editing_door" cycle_type_operator = "bim.cycle_door_type" + # Declarative dimension gizmo configuration with visibility and position + # matrix_position lambdas replace the get_dimension_matrix_* methods dimension_gizmo_props = [ - DimensionGizmoConfig(attr_name="overall_width", axis=(1, 0, 0), min_value=0.01, text_offset_sign=-1), - DimensionGizmoConfig(attr_name="overall_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start"), - DimensionGizmoConfig(attr_name="threshold_thickness", axis=(0, 0, 1)), - DimensionGizmoConfig(attr_name="threshold_depth", axis=(0, 1, 0)), - DimensionGizmoConfig(attr_name="threshold_offset", axis=(0, 1, 0)), - DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0)), - DimensionGizmoConfig(attr_name="lining_depth", axis=(0, 1, 0)), - DimensionGizmoConfig(attr_name="lining_thickness", axis=(-1, 0, 0)), - DimensionGizmoConfig(attr_name="transom_offset", axis=(0, 0, 1)), - DimensionGizmoConfig(attr_name="transom_thickness", axis=(0, 0, 1)), - DimensionGizmoConfig(attr_name="casing_thickness", axis=(-1, 0, 0)), - DimensionGizmoConfig(attr_name="casing_depth", axis=(0, 1, 0)), + DimensionGizmoConfig( + attr_name="overall_width", axis=(1, 0, 0), min_value=0.01, text_offset_sign=-1, + # Position set dynamically in _update_dimension_gizmo_positions based on view + ), + DimensionGizmoConfig( + attr_name="overall_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start", + # Position set dynamically in _update_dimension_gizmo_positions based on view + ), + DimensionGizmoConfig( + attr_name="threshold_thickness", axis=(0, 0, 1), + matrix_position=lambda p: V_(p.overall_width / 2, p.threshold_offset + p.threshold_depth, 0), + ), + DimensionGizmoConfig( + attr_name="threshold_depth", axis=(0, 1, 0), + visibility_condition=lambda p: p.has_threshold_depth(), + matrix_position=lambda p: V_(p.overall_width / 2, p.threshold_offset, p.threshold_thickness), + ), + DimensionGizmoConfig( + attr_name="threshold_offset", axis=(0, 1, 0), + matrix_position=lambda p: V_(p.overall_width / 2 - _G.GIZMO_STACK_OFFSET, 0, p.threshold_thickness), + ), + DimensionGizmoConfig( + attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0, + # Position set dynamically in _update_dimension_gizmo_positions based on view + ), + DimensionGizmoConfig( + attr_name="lining_depth", axis=(0, 1, 0), + matrix_position=lambda p: V_(p.overall_width, p.lining_offset, p.overall_height), + ), + DimensionGizmoConfig( + attr_name="lining_thickness", axis=(-1, 0, 0), + matrix_position=lambda p: V_(p.overall_width, p.lining_depth / 2, p.overall_height / 2), + ), + DimensionGizmoConfig( + attr_name="transom_offset", axis=(0, 0, 1), + visibility_condition=lambda p: p.has_transom(), + matrix_position=lambda p: V_(p.overall_width / 2, p.lining_offset, 0), + ), + DimensionGizmoConfig( + attr_name="transom_thickness", axis=(0, 0, 1), + matrix_position=lambda p: V_(p.overall_width / 2, p.lining_offset, p.transom_offset), + ), + DimensionGizmoConfig( + attr_name="casing_thickness", axis=(-1, 0, 0), + visibility_condition=lambda p: p.has_casing(), + matrix_position=lambda p: V_( + p.lining_thickness, + p.lining_depth + p.lining_offset + p.casing_depth / 2, + p.overall_height / 2 + ), + ), + DimensionGizmoConfig( + attr_name="casing_depth", axis=(0, 1, 0), + visibility_condition=lambda p: p.has_casing_depth(), + matrix_position=lambda p: V_( + p.lining_thickness - p.casing_thickness, + p.lining_depth + p.lining_offset, + p.overall_height / 2 + ), + ), + DimensionGizmoConfig( + attr_name="panel_depth", axis=(0, 1, 0), + matrix_position=lambda p: V_( + p.lining_to_panel_offset_x + p.overall_width * p.panel_width_ratio / 2, + p.lining_offset + p.lining_to_panel_offset_y, + p.threshold_thickness + p.get_panel_center_z() + ), + ), + DimensionGizmoConfig( + attr_name="frame_thickness", axis=(-1, 0, 0), + visibility_condition=lambda p: p.has_transom(), + matrix_position=lambda p: V_( + p.overall_width, + p.lining_offset + p.lining_to_panel_offset_y + p.frame_depth / 2, + p.get_transom_window_center_z() + ), + ), + DimensionGizmoConfig( + attr_name="frame_depth", axis=(0, 1, 0), + visibility_condition=lambda p: p.has_transom(), + matrix_position=lambda p: V_( + p.overall_width - p.frame_thickness, + p.lining_offset + p.lining_to_panel_offset_y, + p.get_transom_window_center_z() + ), + ), ] + props_getter = "get_door_props" + gizmo_pref_name = "door" + @classmethod - def is_element_type(cls, element) -> bool: + def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool: return tool.Blender.Modifier.is_door(element) - def get_props(self, obj: bpy.types.Object): - return tool.Model.get_door_props(obj) + def get_icon_y_extent(self, props: "BIMDoorProperties") -> tuple[float, float]: + """Get Y extents for door icon positioning. - def get_gizmo_prefs(self): - prefs = tool.Blender.get_addon_preferences() - return prefs.gizmos.door - - def should_hide_gizmo(self, attr_name: str, props) -> bool: - """Door-specific visibility rules for gizmos.""" - if not props.is_editing: - return True - if attr_name == "threshold_depth" and props.threshold_thickness == 0.0: - return True - if attr_name == "transom_offset" and props.transom_thickness == 0.0: - return True - if attr_name == "casing_thickness" and props.lining_offset != 0.0: - return True - if attr_name == "casing_depth" and (props.lining_offset != 0.0 or props.casing_thickness == 0.0): - return True - return False - - def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: - """Get Y offset for icons based on view direction. - - Positions icons further than the furthest geometry: - max(threshold_offset + threshold_depth, lining_offset + lining_depth) + 2 * GIZMO_OFFSET + Door geometry extends in +Y direction from lining/threshold. + Icons are positioned beyond max(threshold_offset + depth, lining_offset + depth). """ - obj = context.active_object - if not obj: - return self.ICON_Y_OFFSET - props = self.get_props(obj) furthest_y = ( max( props.threshold_offset + props.threshold_depth, @@ -843,154 +886,53 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): ) + 2 * self.GIZMO_OFFSET ) + return (furthest_y, furthest_y) - viewing_from_negative_y, _ = self.get_local_view_direction(context, mw) - if viewing_from_negative_y: - return -furthest_y - return furthest_y - - def get_dimension_matrix_threshold_thickness(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(props.overall_width / 2, props.threshold_offset + props.threshold_depth, 0), (0, 0, 1) - ) - - def get_dimension_matrix_threshold_depth(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(props.overall_width / 2, props.threshold_offset, props.threshold_thickness), (0, 1, 0) - ) - - def get_dimension_matrix_threshold_offset(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(props.overall_width / 2 - 0.1, 0, props.threshold_thickness), (0, 1, 0) - ) - - def get_dimension_matrix_lining_offset(self, props) -> Matrix: - return self.compose_gizmo_matrix(V_(0, 0, 0), (0, 1, 0)) - - def get_dimension_matrix_lining_depth(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(props.overall_width, props.lining_offset, props.overall_height), (0, 1, 0) - ) - - def get_dimension_matrix_lining_thickness(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(props.overall_width, props.lining_depth / 2, props.overall_height / 2), (-1, 0, 0) - ) - - def get_dimension_matrix_transom_offset(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(props.overall_width / 2, props.lining_offset, 0), (0, 0, 1) - ) - - def get_dimension_matrix_transom_thickness(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(props.overall_width / 2, props.lining_offset, props.transom_offset), (0, 0, 1) - ) - - @staticmethod - def _get_casing_gizmo_base_position(props) -> tuple[float, float, float]: - """Get common base position for casing gizmos.""" - x = props.lining_thickness - y_base = props.lining_depth + props.lining_offset - z = props.overall_height / 2 - return x, y_base, z - - def get_dimension_matrix_casing_thickness(self, props) -> Matrix: - x, y_base, z = self._get_casing_gizmo_base_position(props) - return self.compose_gizmo_matrix(V_(x, y_base + props.casing_depth / 2, z), (-1, 0, 0)) - - def get_dimension_matrix_casing_depth(self, props) -> Matrix: - x, y_base, z = self._get_casing_gizmo_base_position(props) - return self.compose_gizmo_matrix(V_(x - props.casing_thickness, y_base, z), (0, 1, 0)) - - def get_dimension_matrix_overall_width(self, props) -> Matrix: - """Position width dimension below the door.""" - return self.compose_gizmo_matrix( - V_(0, props.lining_offset - self.GIZMO_OFFSET, -self.GIZMO_OFFSET), (1, 0, 0) - ) - - def get_dimension_matrix_overall_height(self, props) -> Matrix: - """Position height dimension to the side of the door.""" - casing_offset = props.casing_thickness if props.lining_offset == 0.0 else 0.0 - return self.compose_gizmo_matrix( - V_(props.overall_width + casing_offset + self.GIZMO_OFFSET, props.lining_offset - self.GIZMO_OFFSET, 0), - (0, 0, 1), - ) - - def setup(self, context: bpy.types.Context) -> None: - self.setup_editing_gizmos(context) - self.setup_dimension_gizmos(context) + def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: + """Create door-specific swing arc gizmos.""" prefs = tool.Blender.get_addon_preferences() - highlight_color = prefs.decorator_color_selected[:3] inactive_color = prefs.decorator_color_background[:3] special_color = prefs.decorator_color_special[:3] - self.gizmo_door_type = self.gizmos.new("VIEW3D_GT_arc") - self.gizmo_door_type.use_draw_scale = False - self.gizmo_door_type.color = special_color - self.gizmo_door_type.alpha = 0.5 - self.gizmo_door_type.color_highlight = highlight_color - self.gizmo_door_type.prop_path = "BIMDoorProperties.door_type" - op = self.gizmo_door_type.target_set_operator("bim.toggle_door_swing") - op.flip_geometry = False + self.gizmo_door_type = self.create_arc_gizmo( + special_color, "bim.toggle_door_swing", + prop_path="BIMDoorProperties.door_type", + flip_geometry=False, + ) + self.gizmo_flip_arc = self.create_arc_gizmo( + inactive_color, "bim.toggle_door_swing", + prop_path="BIMDoorProperties.door_type", + flip_geometry=True, + flip_local_axes="XY", + ) - self.gizmo_flip_arc = self.gizmos.new("VIEW3D_GT_arc") - self.gizmo_flip_arc.use_draw_scale = False - self.gizmo_flip_arc.color = inactive_color - self.gizmo_flip_arc.alpha = 0.5 - self.gizmo_flip_arc.color_highlight = highlight_color - self.gizmo_flip_arc.prop_path = "BIMDoorProperties.door_type" - op = self.gizmo_flip_arc.target_set_operator("bim.toggle_door_swing") - op.flip_geometry = True - op.flip_local_axes = "XY" - - def refresh(self, context: bpy.types.Context) -> None: - if not self.is_setup_complete(): - return - obj = context.active_object - if not obj: - return - - props = self.get_props(obj) - mw = obj.matrix_world + def _refresh_element_specific( + self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties" # noqa: ARG002 + ) -> None: + """Update door-specific swing arc gizmos.""" self.update_swing_gizmos(mw, props) - self.update_editing_gizmos(context, mw, props) - self.update_dimension_gizmos(mw, props) - def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props) -> None: + def get_casing_offset(self, props: "BIMDoorProperties") -> float: + """Override to return casing_thickness when lining_offset is 0.""" + return props.get_casing_offset() + + def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props: "BIMDoorProperties") -> None: """Update dimension gizmo positions based on camera view direction.""" - viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw) - y_pos = self.get_lining_y_position_for_view(props, viewing_from_negative_y) + self._update_view_dependent_dimensions(context, mw, props) - self.set_dimension_gizmo_position("overall_width", mw, V_(0, y_pos, -self.GIZMO_OFFSET), (1, 0, 0)) - - casing_offset = props.casing_thickness if props.lining_offset == 0.0 else 0.0 - if viewing_from_negative_x: - x_pos = -casing_offset - self.GIZMO_OFFSET - else: - x_pos = props.overall_width + casing_offset + self.GIZMO_OFFSET - self.set_dimension_gizmo_position("overall_height", mw, V_(x_pos, y_pos, 0), (0, 0, 1)) - - def update_swing_gizmos(self, mw: Matrix, props) -> None: + def update_swing_gizmos(self, mw: Matrix, props: "BIMDoorProperties") -> None: """Update swing gizmo position and color based on editing state.""" - door_type_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.gizmo_door_type) - flip_arc_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.gizmo_flip_arc) + prefs = tool.Blender.get_addon_preferences() + door_gizmo_prefs = prefs.gizmos.door - if door_type_hidden_by_modal: - self.gizmo_door_type.hide = True - else: - prefs = tool.Blender.get_addon_preferences() - door_gizmo_prefs = prefs.gizmos.door - self.gizmo_door_type.hide = not props.is_editing or not door_gizmo_prefs.swing_arc + 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 + ) - if flip_arc_hidden_by_modal: - self.gizmo_flip_arc.hide = True - else: - prefs = tool.Blender.get_addon_preferences() - door_gizmo_prefs = prefs.gizmos.door - self.gizmo_flip_arc.hide = not props.is_editing or not door_gizmo_prefs.flip_arc - - if self.gizmo_door_type.hide and self.gizmo_flip_arc.hide: + if not door_type_visible and not flip_arc_visible: return swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0 @@ -998,11 +940,10 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): props.overall_width, 4 ) - prefs = tool.Blender.get_addon_preferences() - if not self.gizmo_door_type.hide: + if door_type_visible: self.gizmo_door_type.matrix_basis = mw @ base_swing_transform self.gizmo_door_type.color = prefs.decorations_colour[:3] - if not self.gizmo_flip_arc.hide: + if flip_arc_visible: mirror_y = Matrix.Scale(-1, 4, (0, 1, 0)) self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_y diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index 681fc4d167..0689d4a100 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -29,7 +29,8 @@ from bonsai.bim.module.model.decorator import WallAxisDecorator, SlabDirectionDe from bonsai.bim.module.model.door import update_door_modifier_bmesh from bonsai.bim.module.model.window import update_window_modifier_bmesh from bonsai.bim.module.drawing.decoration import CutDecorator -from typing import TYPE_CHECKING, Literal, get_args, Union, get_args, Any, Optional +from typing import TYPE_CHECKING, Literal, get_args, Union, Any, Optional, Callable +from mathutils import Vector def get_ifc_class(self: "BIMModelProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: @@ -152,6 +153,53 @@ def update_window(self: "BIMWindowProperties", context: bpy.types.Context) -> No update_window_modifier_bmesh(context) +# Lazy-loaded module references for parametric element updates +# Using module-level lazy loading avoids circular imports and repeated import overhead +_updater_cache: dict[str, Callable] = {} + + +def _get_updater(module_name: str, func_name: str) -> Callable: + """Lazy-load an updater function to avoid circular imports. + + Args: + module_name: Module name within bonsai.bim.module.model (e.g., "stair") + func_name: Function name to import (e.g., "regenerate_stair_mesh") + + Returns: + The imported function, cached for subsequent calls. + """ + cache_key = f"{module_name}.{func_name}" + if cache_key not in _updater_cache: + import importlib + module = importlib.import_module(f"bonsai.bim.module.model.{module_name}") + _updater_cache[cache_key] = getattr(module, func_name) + return _updater_cache[cache_key] + + +def update_stair(self: "BIMStairProperties", context: bpy.types.Context) -> None: + """Regenerate stair mesh when property changes.""" + obj = context.active_object + if obj and self.is_editing: + _get_updater("stair", "regenerate_stair_mesh")(obj) + + +def update_railing(self: "BIMRailingProperties", context: bpy.types.Context) -> None: + """Regenerate railing mesh when property changes.""" + if self.is_editing: + # Only FRAMELESS_PANEL can update live via bmesh. + # WALL_MOUNTED_HANDRAIL geometry is generated from IFC representation, + # so it only updates on "Finish Editing" to avoid modifying IFC during preview. + if self.railing_type == "FRAMELESS_PANEL": + _get_updater("railing", "update_railing_modifier_bmesh")(context) + + +def update_roof(self: "BIMRoofProperties", context: bpy.types.Context) -> None: + """Regenerate roof mesh when property changes.""" + obj = context.active_object + if obj and self.is_editing: + _get_updater("roof", "update_roof_modifier_bmesh")(obj) + + class BIMModelProperties(PropertyGroup): ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class) relating_type_id: bpy.props.EnumProperty( @@ -353,153 +401,65 @@ class BIMArrayProperties(PropertyGroup): def update_total_length_target(self: "BIMStairProperties", context: bpy.types.Context) -> None: - """Update tread_run when total_length_target changes""" - # Calculate available length for default treads - available_length = self.total_length_target - n_default_treads = self.number_of_treads + 1 # number of risers - - # Subtract custom first tread if not locked and not zero - if not self.custom_tread_lock and self.custom_first_last_tread_run[0] != 0: - available_length -= self.custom_first_last_tread_run[0] - n_default_treads -= 1 - - # Subtract custom last tread if not locked and not zero - if not self.custom_tread_lock and self.custom_first_last_tread_run[1] != 0: - available_length -= self.custom_first_last_tread_run[1] - n_default_treads -= 1 - - # Calculate tread_run for remaining treads - if n_default_treads > 0: - self["tread_run"] = available_length / n_default_treads - else: - # All treads are custom, just use target length - self["tread_run"] = self.total_length_target / (self.number_of_treads + 1) + """Update tread_run when total_length_target changes.""" + self.update_tread_run_from_length() + # Must call update_stair here because self["prop"] bypasses property update callbacks + update_stair(self, context) def update_tread_run(self: "BIMStairProperties", context: bpy.types.Context) -> None: - """Update either number_of_treads or total_length_target when tread_run changes""" + """Update either number_of_treads or total_length_target when tread_run changes.""" if self.total_length_lock: - # Calculate how much length custom treads take up - custom_length = 0 - n_custom_treads = 0 - - if not self.custom_tread_lock: - if self.custom_first_last_tread_run[0] != 0: - custom_length += self.custom_first_last_tread_run[0] - n_custom_treads += 1 - if self.custom_first_last_tread_run[1] != 0: - custom_length += self.custom_first_last_tread_run[1] - n_custom_treads += 1 - # Calculate how many default treads fit in remaining space + custom_length, custom_count = self.get_custom_tread_info() available_length = self.total_length_target - custom_length if self.tread_run > 0: n_default_treads = available_length / self.tread_run - total_treads = n_default_treads + n_custom_treads # number_of_treads = number_of_risers - 1 - self["number_of_treads"] = int(total_treads - 1) + self["number_of_treads"] = int(n_default_treads + custom_count - 1) else: - # Calculate total length from current settings - n_default_treads = self.number_of_treads + 1 - total_length = 0 - - if not self.custom_tread_lock: - if self.custom_first_last_tread_run[0] != 0: - total_length += self.custom_first_last_tread_run[0] - n_default_treads -= 1 - if self.custom_first_last_tread_run[1] != 0: - total_length += self.custom_first_last_tread_run[1] - n_default_treads -= 1 - - total_length += n_default_treads * self.tread_run - self["total_length_target"] = total_length + self.update_total_length_from_treads() + # Must call update_stair here because self["prop"] bypasses property update callbacks + update_stair(self, context) def update_number_of_treads(self: "BIMStairProperties", context: bpy.types.Context) -> None: - """Update either tread_run or total_length_target when number_of_treads changes""" + """Update either tread_run or total_length_target when number_of_treads changes.""" if self.total_length_lock: - # Calculate available length for default treads - available_length = self.total_length_target - n_default_treads = self.number_of_treads + 1 - - if not self.custom_tread_lock: - if self.custom_first_last_tread_run[0] != 0: - available_length -= self.custom_first_last_tread_run[0] - n_default_treads -= 1 - if self.custom_first_last_tread_run[1] != 0: - available_length -= self.custom_first_last_tread_run[1] - n_default_treads -= 1 - - if n_default_treads > 0: - self["tread_run"] = available_length / n_default_treads - else: - self["tread_run"] = self.total_length_target / (self.number_of_treads + 1) + self.update_tread_run_from_length() else: - # Calculate total length from current settings - n_default_treads = self.number_of_treads + 1 - total_length = 0 - - if not self.custom_tread_lock: - if self.custom_first_last_tread_run[0] != 0: - total_length += self.custom_first_last_tread_run[0] - n_default_treads -= 1 - if self.custom_first_last_tread_run[1] != 0: - total_length += self.custom_first_last_tread_run[1] - n_default_treads -= 1 - - total_length += n_default_treads * self.tread_run - self["total_length_target"] = total_length + self.update_total_length_from_treads() + # Must call update_stair here because self["prop"] bypasses property update callbacks + update_stair(self, context) def update_custom_first_last_tread_run(self: "BIMStairProperties", context: bpy.types.Context) -> None: - """Update tread_run or total_length when custom treads change""" + """Update tread_run or total_length when custom treads change.""" if self.total_length_lock: - # Recalculate tread_run to maintain total length - available_length = self.total_length_target - n_default_treads = self.number_of_treads + 1 - - if not self.custom_tread_lock: - if self.custom_first_last_tread_run[0] != 0: - available_length -= self.custom_first_last_tread_run[0] - n_default_treads -= 1 - if self.custom_first_last_tread_run[1] != 0: - available_length -= self.custom_first_last_tread_run[1] - n_default_treads -= 1 - - if n_default_treads > 0: - self["tread_run"] = available_length / n_default_treads + self.update_tread_run_from_length() else: - # Recalculate total length - n_default_treads = self.number_of_treads + 1 - total_length = 0 - - if not self.custom_tread_lock: - if self.custom_first_last_tread_run[0] != 0: - total_length += self.custom_first_last_tread_run[0] - n_default_treads -= 1 - if self.custom_first_last_tread_run[1] != 0: - total_length += self.custom_first_last_tread_run[1] - n_default_treads -= 1 - - total_length += n_default_treads * self.tread_run - self["total_length_target"] = total_length + self.update_total_length_from_treads() + # Must call update_stair here because self["prop"] bypasses property update callbacks + update_stair(self, context) class BIMStairProperties(PropertyGroup): def validate_nosing_value(self, context: bpy.types.Context) -> None: if self.stair_type != "WOOD/STEEL" and self.nosing_length < 0: self["nosing_length"] = 0 + update_stair(self, context) def update_custom_tread_lock(self, context: bpy.types.Context) -> None: """When lock is enabled, sync custom treads with tread_run""" if self.custom_tread_lock: self["custom_first_last_tread_run"] = (self.tread_run, self.tread_run) + update_stair(self, context) non_si_units_props = ("is_editing", "number_of_treads", "has_top_nib", "stair_type", "custom_tread_lock") is_editing: bpy.props.BoolProperty(default=False) - width: bpy.props.FloatProperty(name="Width", default=1.2, min=0.01, subtype="DISTANCE") - height: bpy.props.FloatProperty(name="Height", default=1.0, min=0.01, subtype="DISTANCE") + width: bpy.props.FloatProperty(name="Width", default=1.2, min=0.01, subtype="DISTANCE", update=update_stair) + height: bpy.props.FloatProperty(name="Height", default=1.0, min=0.01, subtype="DISTANCE", update=update_stair) number_of_treads: bpy.props.IntProperty( name="Number of Treads", default=6, soft_min=1, min=0, update=update_number_of_treads ) @@ -516,13 +476,13 @@ class BIMStairProperties(PropertyGroup): name="Lock Total Length", description="Lock Total Length when changing number of treads or tread run", ) - tread_depth: bpy.props.FloatProperty(name="Tread Depth", default=0.25, min=0.01, subtype="DISTANCE") + tread_depth: bpy.props.FloatProperty(name="Tread Depth", default=0.25, min=0.01, subtype="DISTANCE", update=update_stair) tread_run: bpy.props.FloatProperty( name="Tread Run", default=0.3, min=0.01, subtype="DISTANCE", update=update_tread_run ) - base_slab_depth: bpy.props.FloatProperty(name="Base Slab Depth", default=0.25, min=0, subtype="DISTANCE") - top_slab_depth: bpy.props.FloatProperty(name="Top Slab Depth", default=0.25, min=0, subtype="DISTANCE") - has_top_nib: bpy.props.BoolProperty(name="Has Top Nib", default=True) + base_slab_depth: bpy.props.FloatProperty(name="Base Slab Depth", default=0.25, min=0, subtype="DISTANCE", update=update_stair) + top_slab_depth: bpy.props.FloatProperty(name="Top Slab Depth", default=0.25, min=0, subtype="DISTANCE", update=update_stair) + has_top_nib: bpy.props.BoolProperty(name="Has Top Nib", default=True, update=update_stair) stair_type: bpy.props.EnumProperty( name="Stair Type", items=[(i, i.replace("/", " / ").title(), "") for i in get_args(tool.Model.StairType)], @@ -555,7 +515,7 @@ class BIMStairProperties(PropertyGroup): update=validate_nosing_value, ) nosing_depth: bpy.props.FloatProperty( - name="Nosing Depth", description="Depth of the tread's nosing", min=0, default=0, unit="LENGTH" + name="Nosing Depth", description="Depth of the tread's nosing", min=0, default=0, unit="LENGTH", update=update_stair ) if TYPE_CHECKING: @@ -659,6 +619,100 @@ class BIMStairProperties(PropertyGroup): continue setattr(target_props, prop_name, prop_value) + def is_concrete_stair(self) -> bool: + return self.stair_type == "CONCRETE" + + def has_nosing(self) -> bool: + return self.nosing_length != 0.0 and self.stair_type != "WOOD/STEEL" + + def has_custom_treads(self) -> bool: + return not self.custom_tread_lock + + def has_tread_run_gizmo(self) -> bool: + return self.custom_tread_lock or self.number_of_treads > 2 + + def has_tread_depth(self) -> bool: + return self.stair_type != "GENERIC" + + def get_riser_height(self) -> float: + """Compute the riser height from total height and number of treads.""" + return self.height / (self.number_of_treads + 1) + + def set_riser_height(self, value: float) -> None: + """Apply riser height by adjusting the total stair height.""" + self.height = max(0.01, value) * (self.number_of_treads + 1) + + def get_total_run(self) -> float: + """Calculate the total horizontal run of the stair. + + Takes into account custom first/last tread runs when custom_tread_lock is False. + """ + number_of_rises = self.number_of_treads + 1 + total_run = 0.0 + default_rises = number_of_rises + + if not self.custom_tread_lock: + if self.custom_first_last_tread_run[0] is not None: # May be 0 though + default_rises -= 1 + total_run += self.custom_first_last_tread_run[0] + if self.custom_first_last_tread_run[1] is not None: # May be 0 though + default_rises -= 1 + total_run += self.custom_first_last_tread_run[1] + + total_run += self.tread_run * default_rises + return total_run + + def get_custom_tread_run(self, index: int) -> float: + """Get custom tread run value for first (0) or last (1) tread.""" + return self.custom_first_last_tread_run[index] + + def set_custom_tread_run(self, index: int, value: float) -> None: + """Set custom tread run value for first (0) or last (1) tread.""" + current = self.custom_first_last_tread_run + if index == 0: + self.custom_first_last_tread_run = (max(0.01, value), current[1]) + else: + self.custom_first_last_tread_run = (current[0], max(0.01, value)) + + def get_custom_tread_info(self) -> tuple[float, int]: + """Calculate total custom tread length and count. + + Returns: + Tuple of (custom_length, custom_count) + """ + custom_length = 0.0 + custom_count = 0 + if not self.custom_tread_lock: + if self.custom_first_last_tread_run[0] != 0: + custom_length += self.custom_first_last_tread_run[0] + custom_count += 1 + if self.custom_first_last_tread_run[1] != 0: + custom_length += self.custom_first_last_tread_run[1] + custom_count += 1 + return custom_length, custom_count + + def calculate_total_length(self) -> float: + """Calculate total stair run length from current properties.""" + custom_length, custom_count = self.get_custom_tread_info() + n_default_treads = self.number_of_treads + 1 - custom_count + return custom_length + n_default_treads * self.tread_run + + def update_tread_run_from_length(self) -> None: + """Recalculate tread_run to maintain total_length_target.""" + custom_length, custom_count = self.get_custom_tread_info() + available_length = self.total_length_target - custom_length + n_default_treads = self.number_of_treads + 1 - custom_count + + if n_default_treads > 0: + self["tread_run"] = available_length / n_default_treads + else: + # All treads are custom, use fallback + self["tread_run"] = self.total_length_target / (self.number_of_treads + 1) + + def update_total_length_from_treads(self) -> None: + """Recalculate total_length_target from current tread settings.""" + self["total_length_target"] = self.calculate_total_length() + class BIMSverchokProperties(PropertyGroup): node_group: bpy.props.PointerProperty(name="Node Group", type=NodeTree) @@ -875,6 +929,167 @@ class BIMWindowProperties(PropertyGroup): if prop_name not in exclude_props: setattr(target_props, prop_name, prop_value) + # Window type feature mapping - centralized configuration for all window type checks + # Each window type maps to its features: mullion, second_mullion, transom, second_transom, panels + WINDOW_TYPE_FEATURES: dict[str, dict[str, bool | int]] = { + "SINGLE_PANEL": {"panels": 1}, + "DOUBLE_PANEL_VERTICAL": {"mullion": True, "panels": 2}, + "DOUBLE_PANEL_HORIZONTAL": {"transom": True, "panels": 2}, + "TRIPLE_PANEL_BOTTOM": {"mullion": True, "transom": True, "panels": 3}, + "TRIPLE_PANEL_TOP": {"mullion": True, "transom": True, "panels": 3}, + "TRIPLE_PANEL_LEFT": {"mullion": True, "transom": True, "panels": 3}, + "TRIPLE_PANEL_RIGHT": {"mullion": True, "transom": True, "panels": 3}, + "TRIPLE_PANEL_HORIZONTAL": {"transom": True, "second_transom": True, "panels": 3}, + "TRIPLE_PANEL_VERTICAL": {"mullion": True, "second_mullion": True, "panels": 3}, + } + + def _get_feature(self, feature: str, default: bool | int = False) -> bool | int: + return self.WINDOW_TYPE_FEATURES.get(self.window_type, {}).get(feature, default) + + def has_mullion(self) -> bool: + return bool(self._get_feature("mullion")) + + def has_second_mullion(self) -> bool: + return bool(self._get_feature("second_mullion")) + + def has_transom(self) -> bool: + return bool(self._get_feature("transom")) + + def has_second_transom(self) -> bool: + return bool(self._get_feature("second_transom")) + + def has_second_panel(self) -> bool: + return int(self._get_feature("panels", 1)) >= 2 + + def has_third_panel(self) -> bool: + return int(self._get_feature("panels", 1)) >= 3 + + def get_lining_to_panel_offset_y_full(self) -> float: + """Get the full Y offset for lining-to-panel positioning.""" + return (self.lining_depth - self.frame_depth[0]) + self.lining_to_panel_offset_y + + def get_panel_geometry(self, panel_index: int) -> tuple[float, float, float, float]: + """Get panel geometry (x_offset, z_offset, height, center_z) for a given panel index. + + Args: + panel_index: 0 for first panel, 1 for second, 2 for third + + Returns: + Tuple of (x_offset, z_offset, height, center_z) + """ + window_type = self.window_type + + if panel_index == 0: + # First panel position and height + if window_type == "DOUBLE_PANEL_HORIZONTAL": + x, z = 0, self.first_transom_offset + elif window_type == "TRIPLE_PANEL_HORIZONTAL": + x, z = 0, self.second_transom_offset + elif window_type in ("TRIPLE_PANEL_BOTTOM", "TRIPLE_PANEL_RIGHT", "TRIPLE_PANEL_TOP"): + x, z = 0, self.first_transom_offset + else: + x, z = 0, 0 + + if window_type == "TRIPLE_PANEL_HORIZONTAL": + height = self.overall_height - self.second_transom_offset + elif window_type in ( + "DOUBLE_PANEL_HORIZONTAL", + "TRIPLE_PANEL_BOTTOM", + "TRIPLE_PANEL_RIGHT", + "TRIPLE_PANEL_TOP", + ): + height = self.overall_height - self.first_transom_offset + else: + height = self.overall_height + + elif panel_index == 1: + # Second panel position and height + if window_type == "DOUBLE_PANEL_VERTICAL": + x, z = self.first_mullion_offset, 0 + elif window_type == "DOUBLE_PANEL_HORIZONTAL": + x, z = 0, 0 + elif window_type in ("TRIPLE_PANEL_BOTTOM", "TRIPLE_PANEL_LEFT", "TRIPLE_PANEL_RIGHT"): + x, z = self.first_mullion_offset, self.first_transom_offset + elif window_type == "TRIPLE_PANEL_TOP": + x, z = 0, 0 + elif window_type == "TRIPLE_PANEL_HORIZONTAL": + x, z = 0, self.first_transom_offset + elif window_type == "TRIPLE_PANEL_VERTICAL": + x, z = self.first_mullion_offset, 0 + else: + x, z = 0, 0 + + if window_type == "DOUBLE_PANEL_HORIZONTAL": + height = self.first_transom_offset + elif window_type == "DOUBLE_PANEL_VERTICAL": + height = self.overall_height + elif window_type in ("TRIPLE_PANEL_BOTTOM", "TRIPLE_PANEL_LEFT", "TRIPLE_PANEL_RIGHT"): + height = self.overall_height - self.first_transom_offset + elif window_type == "TRIPLE_PANEL_TOP": + height = self.first_transom_offset + elif window_type == "TRIPLE_PANEL_HORIZONTAL": + height = self.second_transom_offset - self.first_transom_offset + elif window_type == "TRIPLE_PANEL_VERTICAL": + height = self.overall_height + else: + height = self.overall_height + + else: # panel_index == 2 + # Third panel position and height + if window_type in ("TRIPLE_PANEL_BOTTOM", "TRIPLE_PANEL_RIGHT", "TRIPLE_PANEL_HORIZONTAL"): + x, z = 0, 0 + elif window_type in ("TRIPLE_PANEL_TOP", "TRIPLE_PANEL_LEFT"): + x, z = self.first_mullion_offset, 0 + elif window_type == "TRIPLE_PANEL_VERTICAL": + x, z = self.second_mullion_offset, 0 + else: + x, z = 0, 0 + + height = self.overall_height if window_type == "TRIPLE_PANEL_VERTICAL" else self.first_transom_offset + + center_z = z + height / 2 + return x, z, height, center_z + + def get_frame_position(self, panel_index: int, is_depth: bool) -> Vector: + """Get frame gizmo position for a given panel. + + Args: + panel_index: 0 for first panel, 1 for second, 2 for third + is_depth: True for depth gizmo, False for thickness gizmo + + Returns: + Position vector for the gizmo + """ + x_offset, _, _, center_z = self.get_panel_geometry(panel_index) + frame_depth = self.frame_depth[panel_index] + y_full = (self.lining_depth - frame_depth) + self.lining_to_panel_offset_y + y_pos = y_full + frame_depth + self.lining_offset + return Vector((x_offset + self.lining_to_panel_offset_x, y_pos, center_z)) + + def get_frame_value(self, attr_name: str, panel_index: int) -> float: + """Get frame property value (frame_depth or frame_thickness) for a specific panel. + + Args: + attr_name: Property name ("frame_depth" or "frame_thickness") + panel_index: Panel index (0, 1, or 2) + + Returns: + The value at the specified panel index + """ + return getattr(self, attr_name)[panel_index] + + def set_frame_value(self, attr_name: str, panel_index: int, value: float) -> None: + """Set frame property value (frame_depth or frame_thickness) for a specific panel. + + Args: + attr_name: Property name ("frame_depth" or "frame_thickness") + panel_index: Panel index (0, 1, or 2) + value: New value (clamped to min 0.0) + """ + current = getattr(self, attr_name) + new_value = tuple(current[:panel_index]) + (max(0.0, value),) + tuple(current[panel_index + 1:]) + setattr(self, attr_name, new_value) + class BIMDoorProperties(PropertyGroup): non_si_units_props = ( @@ -1138,6 +1353,36 @@ class BIMDoorProperties(PropertyGroup): if prop_name not in exclude_props: setattr(target_props, prop_name, prop_value) + def has_threshold_depth(self) -> bool: + """Check if threshold depth gizmo should be visible (has threshold).""" + return self.threshold_thickness > 0.0 + + def has_transom(self) -> bool: + """Check if transom-related gizmos should be visible (has transom).""" + return self.transom_thickness > 0.0 + + def has_casing(self) -> bool: + """Check if casing gizmos should be visible (no lining offset).""" + return self.lining_offset == 0.0 + + def has_casing_depth(self) -> bool: + """Check if casing depth gizmo should be visible (has casing and casing thickness > 0).""" + return self.lining_offset == 0.0 and self.casing_thickness > 0.0 + + def get_panel_center_z(self) -> float: + """Get the vertical center of the door panel for gizmo positioning.""" + if self.transom_thickness > 0: + return (self.transom_offset - self.threshold_thickness) / 2 + return (self.overall_height - self.threshold_thickness) / 2 + + def get_transom_window_center_z(self) -> float: + """Get the vertical center of the transom window for frame gizmo positioning.""" + return (self.transom_offset + self.transom_thickness / 2 + self.overall_height - self.lining_thickness) / 2 + + def get_casing_offset(self) -> float: + """Get casing offset for gizmo positioning (casing_thickness when lining_offset is 0).""" + return self.casing_thickness if self.lining_offset == 0.0 else 0.0 + RailingType = Literal["FRAMELESS_PANEL", "WALL_MOUNTED_HANDRAIL"] CapType = Literal["TO_END_POST_AND_FLOOR", "TO_END_POST", "TO_FLOOR", "TO_WALL", "180", "NONE"] @@ -1156,11 +1401,11 @@ class BIMRailingProperties(PropertyGroup): is_editing_path: bpy.props.BoolProperty(default=False) railing_type: bpy.props.EnumProperty( - name="Railing Type", items=[(i, i, "") for i in get_args(RailingType)], default="FRAMELESS_PANEL" + name="Railing Type", items=[(i, i, "") for i in get_args(RailingType)], default="FRAMELESS_PANEL", update=update_railing ) - height: bpy.props.FloatProperty(name="Height", default=1.0, subtype="DISTANCE") - thickness: bpy.props.FloatProperty(name="Thickness", default=0.050, subtype="DISTANCE") - spacing: bpy.props.FloatProperty(name="Spacing", default=0.050, subtype="DISTANCE") + height: bpy.props.FloatProperty(name="Height", default=1.0, subtype="DISTANCE", update=update_railing) + thickness: bpy.props.FloatProperty(name="Thickness", default=0.050, subtype="DISTANCE", update=update_railing) + spacing: bpy.props.FloatProperty(name="Spacing", default=0.050, subtype="DISTANCE", update=update_railing) # wall mounted handrail specific properties use_manual_supports: bpy.props.BoolProperty( @@ -1168,6 +1413,7 @@ class BIMRailingProperties(PropertyGroup): default=False, description="If enabled, supports are added on every vertex on the edges of the railing path.\n" "If disabled, supports are added automatically based on the support spacing", + update=update_railing, ) support_spacing: bpy.props.FloatProperty( name="Support Spacing", @@ -1175,16 +1421,18 @@ class BIMRailingProperties(PropertyGroup): min=0.01, description="Distance between supports if automatic supports are used", subtype="DISTANCE", + update=update_railing, ) - railing_diameter: bpy.props.FloatProperty(name="Railing Diameter", default=0.050, subtype="DISTANCE") + railing_diameter: bpy.props.FloatProperty(name="Railing Diameter", default=0.050, subtype="DISTANCE", update=update_railing) clear_width: bpy.props.FloatProperty( name="Clear Width", default=0.040, description="Clear width between the railing and the wall", subtype="DISTANCE", + update=update_railing, ) terminal_type: bpy.props.EnumProperty( - name="Terminal Type", items=[(i, i, "") for i in get_args(CapType)], default="180" + name="Terminal Type", items=[(i, i, "") for i in get_args(CapType)], default="180", update=update_railing ) if TYPE_CHECKING: @@ -1258,9 +1506,11 @@ RoofGenerationMethod = Literal["HEIGHT", "ANGLE"] class BIMRoofProperties(PropertyGroup): def update_angle(self, context: bpy.types.Context) -> None: self["angle"] = to_angle(self.percentage) + update_roof(self, context) def update_percentage(self, context: bpy.types.Context) -> None: self["percentage"] = to_percentage(self.angle) + update_roof(self, context) non_si_units_props = ( "is_editing", @@ -1276,13 +1526,13 @@ class BIMRoofProperties(PropertyGroup): is_editing_path: bpy.props.BoolProperty(default=False) roof_type: bpy.props.EnumProperty( - name="Roof Type", items=[(i, i, "") for i in get_args(RoofType)], default="HIP/GABLE ROOF" + name="Roof Type", items=[(i, i, "") for i in get_args(RoofType)], default="HIP/GABLE ROOF", update=update_roof ) generation_method: bpy.props.EnumProperty( - name="Roof Generation Method", items=[(i, i, "") for i in get_args(RoofGenerationMethod)], default="ANGLE" + name="Roof Generation Method", items=[(i, i, "") for i in get_args(RoofGenerationMethod)], default="ANGLE", update=update_roof ) height: bpy.props.FloatProperty( - name="Height", default=1.0, description="Maximum height of the roof to be generated.", subtype="DISTANCE" + name="Height", default=1.0, description="Maximum height of the roof to be generated.", subtype="DISTANCE", update=update_roof ) angle: bpy.props.FloatProperty( name="Slope Angle", @@ -1304,9 +1554,9 @@ class BIMRoofProperties(PropertyGroup): soft_min=to_percentage(radians(5.0)), soft_max=to_percentage(radians(60.0)), ) - roof_thickness: bpy.props.FloatProperty(name="Roof Thickness", default=0.1, subtype="DISTANCE") + roof_thickness: bpy.props.FloatProperty(name="Roof Thickness", default=0.1, subtype="DISTANCE", update=update_roof) rafter_edge_angle: bpy.props.FloatProperty( - name="Rafter Edge Angle", min=0, max=pi / 2, default=pi / 2, subtype="ANGLE" + name="Rafter Edge Angle", min=0, max=pi / 2, default=pi / 2, subtype="ANGLE", update=update_roof ) if TYPE_CHECKING: diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index 4611c1b0a0..bf41e8d643 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -19,7 +19,6 @@ import bpy import json import bmesh -import math import ifcopenshell import ifcopenshell.api.pset import ifcopenshell.util.element @@ -34,10 +33,11 @@ from mathutils import Vector, Matrix V_ = tool.Blender.V_ from bmesh.types import BMVert -from bpy.types import Operator -from bpy.props import FloatProperty, IntProperty -from bpy_extras.object_utils import AddObjectHelper, object_data_add -from typing import get_args +from bpy.props import IntProperty +from typing import get_args, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.model.prop import BIMStairProperties def regenerate_stair_mesh(obj: bpy.types.Object) -> None: @@ -142,10 +142,10 @@ class BIM_OT_add_stair(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} @classmethod - def poll(cls, context): + def poll(cls, context: bpy.types.Context) -> bool: return tool.Ifc.get() and context.mode == "OBJECT" - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: ifc_file = tool.Ifc.get() if not ifc_file: self.report({"ERROR"}, "You need to start IFC project first to create a stair.") @@ -187,7 +187,7 @@ class AddStair(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Add Bonsai parametric stair to the active IFC element" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: obj = context.active_object assert obj element = tool.Ifc.get_entity(obj) @@ -216,6 +216,7 @@ class AddStair(bpy.types.Operator, tool.Ifc.Operator): regenerate_stair_mesh(obj) update_ifc_stair_props(obj) tool.Model.add_body_representation(obj) + return {"FINISHED"} class CancelEditingStair(bpy.types.Operator, tool.Ifc.Operator): @@ -224,7 +225,7 @@ class CancelEditingStair(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Cancel editing and revert stair parameters to their previous values" bl_options = {"REGISTER"} - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: obj = context.active_object assert obj element = tool.Ifc.get_entity(obj) @@ -246,7 +247,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Apply changes and finish editing stair parameters" bl_options = {"REGISTER"} - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: obj = context.active_object assert obj element = tool.Ifc.get_entity(obj) @@ -274,7 +275,7 @@ class EnableEditingStair(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Enter edit mode to modify stair parameters interactively" bl_options = {"REGISTER"} - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: obj = context.active_object assert obj props = tool.Model.get_stair_props(obj) @@ -291,7 +292,7 @@ class RemoveStair(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Remove Stair" bl_options = {"REGISTER"} - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: obj = context.active_object assert obj props = tool.Model.get_stair_props(obj) @@ -305,40 +306,40 @@ class RemoveStair(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class ToggleStairTotalLengthLock(bpy.types.Operator): - """Toggle the total length lock for stair editing""" +class ToggleStairProperty(bpy.types.Operator): + """Toggle a boolean property on stair properties""" - bl_idname = "bim.toggle_stair_total_length_lock" - bl_label = "Toggle Stair Total Length Lock" + bl_idname = "bim.toggle_stair_property" + bl_label = "Toggle Stair Property" bl_options = {"REGISTER", "UNDO"} - def execute(self, context): + property_name: bpy.props.StringProperty( + name="Property Name", + description="Name of the boolean property to toggle", + options={"HIDDEN", "SKIP_SAVE"}, + ) + + # Map property names to their descriptions + PROPERTY_DESCRIPTIONS: dict[str, str] = { + "total_length_lock": "Lock/unlock total stair length. When locked, changing treads adjusts tread depth", + "custom_tread_lock": "Lock/unlock first and last tread dimensions. When unlocked, they can differ from other treads", + } + + @classmethod + def description(cls, context: bpy.types.Context, properties: bpy.types.OperatorProperties) -> str: + prop_name = properties.property_name + return cls.PROPERTY_DESCRIPTIONS.get(prop_name, "Toggle a boolean property on stair properties") + + def execute(self, context: bpy.types.Context) -> set[str]: obj = context.active_object - if not obj: + if not obj or not self.property_name: return {"CANCELLED"} props = tool.Model.get_stair_props(obj) - props.total_length_lock = not props.total_length_lock - - return {"FINISHED"} - - -class ToggleStairCustomTreadLock(bpy.types.Operator): - """Toggle custom first/last tread runs. When unlocked, first and last treads can have different lengths.""" - - bl_idname = "bim.toggle_stair_custom_tread_lock" - bl_label = "Toggle Custom Tread Lock" - bl_options = {"REGISTER", "UNDO"} - - def execute(self, context): - obj = context.active_object - if not obj: - return {"CANCELLED"} - - props = tool.Model.get_stair_props(obj) - props.custom_tread_lock = not props.custom_tread_lock - - return {"FINISHED"} + if hasattr(props, self.property_name): + setattr(props, self.property_name, not getattr(props, self.property_name)) + return {"FINISHED"} + return {"CANCELLED"} class AdjustStairTreads(bpy.types.Operator): @@ -350,13 +351,13 @@ class AdjustStairTreads(bpy.types.Operator): increment: IntProperty(name="Increment", default=1) - def invoke(self, context, event): + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: if event.shift: bpy.ops.bim.set_stair_treads("INVOKE_DEFAULT") return {"FINISHED"} return self.execute(context) - def execute(self, context): + def execute(self, context: bpy.types.Context) -> set[str]: obj = context.active_object if not obj: return {"CANCELLED"} @@ -376,7 +377,7 @@ class SetStairTreads(bpy.types.Operator): bl_label = "Set Number of Treads" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - def invoke(self, context, event): # noqa: ARG002 + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: # noqa: ARG002 obj = context.active_object if not obj: return {"CANCELLED"} @@ -390,10 +391,10 @@ class SetStairTreads(bpy.types.Operator): update_header(context, self._format_header()) return {"RUNNING_MODAL"} - def modal(self, context, event): + def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: return run_integer_input_modal(self, context, event) - def _apply_value(self, context) -> None: + def _apply_value(self, context: bpy.types.Context) -> None: obj = context.active_object if not obj: return @@ -402,7 +403,7 @@ class SetStairTreads(bpy.types.Operator): props = tool.Model.get_stair_props(obj) props.number_of_treads = value - def _restore_value(self, context) -> None: + def _restore_value(self, context: bpy.types.Context) -> None: obj = context.active_object if obj: props = tool.Model.get_stair_props(obj) @@ -414,51 +415,36 @@ class SetStairTreads(bpy.types.Operator): return f"Number of Treads: {input_str}_{validity} | Enter to confirm, Esc to cancel" -class CycleStairType(bpy.types.Operator): +class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin): """Cycle through stair types. Shift+click to cycle in reverse.""" bl_idname = "bim.cycle_stair_type" bl_label = "Cycle Stair Type" bl_options = {"REGISTER", "UNDO"} - reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"}) + props_getter = "get_stair_props" + type_literal = tool.Model.StairType + type_attr = "stair_type" + skip_element_check = True - def invoke(self, context, event): - self.reverse = event.shift - return self.execute(context) - - def execute(self, context): - obj = context.active_object - if not obj: - return {"CANCELLED"} - - props = tool.Model.get_stair_props(obj) - stair_types = get_args(tool.Model.StairType) - current_idx = stair_types.index(props.stair_type) if props.stair_type in stair_types else 0 - direction = -1 if self.reverse else 1 - props.stair_type = stair_types[(current_idx + direction) % len(stair_types)] - - return {"FINISHED"} + def execute(self, context: bpy.types.Context) -> set[str]: + return self._cycle_type(context) -def _compute_first_tread_run(props) -> float: - """Get the first custom tread run value from the tuple property.""" - return props.custom_first_last_tread_run[0] +# Tread run accessors - callbacks that delegate to BIMStairProperties methods +_tread_run_accessors = { + 0: ( + lambda props: props.get_custom_tread_run(0), + lambda props, value: props.set_custom_tread_run(0, value), + ), + 1: ( + lambda props: props.get_custom_tread_run(1), + lambda props, value: props.set_custom_tread_run(1, value), + ), +} - -def _apply_first_tread_run(props, value: float) -> None: - """Apply a new first custom tread run value, preserving the second value.""" - props.custom_first_last_tread_run = (max(0.01, value), props.custom_first_last_tread_run[1]) - - -def _compute_last_tread_run(props) -> float: - """Get the last custom tread run value from the tuple property.""" - return props.custom_first_last_tread_run[1] - - -def _apply_last_tread_run(props, value: float) -> None: - """Apply a new last custom tread run value, preserving the first value.""" - props.custom_first_last_tread_run = (props.custom_first_last_tread_run[0], max(0.01, value)) +# Shorthand for gizmo offset constants used in DimensionGizmoConfig lambdas +_G = gizmo.BaseParametricGizmoGroup class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): @@ -475,28 +461,22 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): ICON_PLUS_X = 1.61 # X position for add tread (+) icon ICON_MINUS_X = 1.98 # X position for remove tread (-) icon ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger) + ICON_CYCLE_SCALE = 0.3 # Scale for cycle type icon + ICON_Z_OFFSET = 0.5 # Z offset above geometry for editing icons enable_editing_operator = "bim.enable_editing_stair" finish_editing_operator = "bim.finish_editing_stair" cancel_editing_operator = "bim.cancel_editing_stair" cycle_type_operator = "bim.cycle_stair_type" - def get_icon_y_offset(self, context, mw): - """Get Y offset for icons based on view direction. + def get_icon_y_extent(self, props: "BIMStairProperties") -> tuple[float, float]: + """Get Y extents for stair icon positioning. - Positions icons further than the furthest geometry: - stair_width + 2 * GIZMO_OFFSET + Stair geometry extends from Y=0 to Y=width. + Icons are positioned beyond the width on either side. """ - obj = context.active_object - if not obj: - return self.ICON_Y_OFFSET - props = self.get_props(obj) furthest_y = props.width + 2 * self.GIZMO_OFFSET - - viewing_from_negative_y, _ = self.get_local_view_direction(context, mw) - if viewing_from_negative_y: - return -furthest_y - return furthest_y + return (furthest_y, furthest_y) dimension_gizmo_props = [ DimensionGizmoConfig( @@ -505,267 +485,164 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): prop_name="Total Length", min_value=0.01, text_offset_sign=-1, + matrix_position=lambda p: V_(0, -_G.GIZMO_OFFSET, -_G.GIZMO_OFFSET), + ), + DimensionGizmoConfig( + attr_name="height", axis=(0, 0, 1), min_value=0.01, text_alignment="start", + matrix_position=lambda p: V_(p.get_total_run() + _G.GIZMO_OFFSET, -_G.GIZMO_OFFSET, 0), + ), + DimensionGizmoConfig( + attr_name="width", axis=(0, 1, 0), min_value=0.01, + matrix_position=lambda p: V_(_G.GIZMO_OFFSET, 0, -_G.GIZMO_OFFSET), ), - DimensionGizmoConfig(attr_name="height", axis=(0, 0, 1), min_value=0.01, text_alignment="start"), - DimensionGizmoConfig(attr_name="width", axis=(0, 1, 0), min_value=0.01), DimensionGizmoConfig( attr_name="tread_run", axis=(1, 0, 0), min_value=0.01, - visibility_condition=lambda props: props.custom_tread_lock or props.number_of_treads > 2, + visibility_condition=lambda p: p.has_tread_run_gizmo(), + matrix_position=lambda p: V_( + 0 if p.custom_tread_lock else p.custom_first_last_tread_run[0], + 0, + p.get_riser_height() if p.custom_tread_lock else p.get_riser_height() * 2 + ), ), DimensionGizmoConfig( attr_name="custom_first_tread_run", axis=(1, 0, 0), prop_name="First Tread", min_value=0.01, - visibility_condition=lambda props: not props.custom_tread_lock, - compute_value=_compute_first_tread_run, - apply_value=_apply_first_tread_run, + visibility_condition=lambda p: p.has_custom_treads(), + compute_value=_tread_run_accessors[0][0], + apply_value=_tread_run_accessors[0][1], + matrix_position=lambda p: V_(0, 0, p.get_riser_height()), ), DimensionGizmoConfig( attr_name="custom_last_tread_run", axis=(1, 0, 0), prop_name="Last Tread", min_value=0.01, - visibility_condition=lambda props: not props.custom_tread_lock, - compute_value=_compute_last_tread_run, - apply_value=_apply_last_tread_run, + visibility_condition=lambda p: p.has_custom_treads(), + compute_value=_tread_run_accessors[1][0], + apply_value=_tread_run_accessors[1][1], + matrix_position=lambda p: V_( + p.get_total_run() - p.custom_first_last_tread_run[1], 0, p.height + ), + ), + DimensionGizmoConfig( + attr_name="nosing_length", axis=(-1, 0, 0), + matrix_position=lambda p: V_(0, p.width / 2, p.get_riser_height()), ), - DimensionGizmoConfig(attr_name="nosing_length", axis=(-1, 0, 0)), DimensionGizmoConfig( attr_name="tread_depth", axis=(0, 0, -1), - visibility_condition=lambda props: props.stair_type != "GENERIC", + visibility_condition=lambda p: p.has_tread_depth(), + matrix_position=lambda p: V_(0, 0, p.get_riser_height()), ), DimensionGizmoConfig( attr_name="riser_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start", - compute_value=lambda props: props.height / (props.number_of_treads + 1), - apply_value=lambda props, value: setattr(props, "height", max(0.01, value) * (props.number_of_treads + 1)), + compute_value=lambda p: p.get_riser_height(), + apply_value=lambda p, v: p.set_riser_height(v), + matrix_position=lambda p: V_(p.tread_run, p.width, 0), ), DimensionGizmoConfig( attr_name="nosing_depth", axis=(0, 0, -1), - visibility_condition=lambda props: props.nosing_length != 0.0 and props.stair_type != "WOOD/STEEL", + visibility_condition=lambda p: p.has_nosing(), + matrix_position=lambda p: V_(-p.nosing_length, p.width / 2, p.get_riser_height()), ), DimensionGizmoConfig( attr_name="base_slab_depth", axis=(0, 0, -1), - visibility_condition=lambda props: props.stair_type == "CONCRETE", + visibility_condition=lambda p: p.is_concrete_stair(), + matrix_position=lambda p: V_(0, p.width / 2, 0), ), DimensionGizmoConfig( attr_name="top_slab_depth", axis=(0, 0, -1), - visibility_condition=lambda props: props.stair_type == "CONCRETE", + visibility_condition=lambda p: p.is_concrete_stair(), + matrix_position=lambda p: V_(p.get_total_run(), p.width / 2, p.height), ), ] + # Metadata-driven dispatch for props and preferences + props_getter = "get_stair_props" + gizmo_pref_name = "stair" + @classmethod - def is_element_type(cls, element) -> bool: + def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool: return tool.Blender.Modifier.is_stair(element) - def get_props(self, obj: bpy.types.Object): - return tool.Model.get_stair_props(obj) + def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None: + """Create stair-specific icon gizmos (lock, plus, minus).""" + self.lock_gizmo = self.create_icon_gizmo( + "VIEW3D_GT_lock", self.COLOR_BLUE, "bim.toggle_stair_property", + prop_path="BIMStairProperties.total_length_lock", + property_name="total_length_lock", + ) + self.tread_lock_gizmo = self.create_icon_gizmo( + "VIEW3D_GT_lock", (1.0, 1.0, 1.0), "bim.toggle_stair_property", + prop_path="BIMStairProperties.custom_tread_lock", + property_name="custom_tread_lock", + ) + self.plus_gizmo = self.create_icon_gizmo( + "VIEW3D_GT_plus", self.COLOR_GREEN, "bim.adjust_stair_treads", increment=1 + ) + self.minus_gizmo = self.create_icon_gizmo( + "VIEW3D_GT_minus", self.COLOR_RED, "bim.adjust_stair_treads", increment=-1 + ) - def get_gizmo_prefs(self): - prefs = tool.Blender.get_addon_preferences() - return prefs.gizmos.stair - - @staticmethod - def _get_stair_total_run(props) -> float: - """Calculate the total horizontal run of the stair. - - Takes into account custom first/last tread runs when custom_tread_lock is False. - """ - number_of_rises = props.number_of_treads + 1 - total_run = 0.0 - default_rises = number_of_rises - - if not props.custom_tread_lock: - if props.custom_first_last_tread_run[0] is not None: # May be 0 though - default_rises -= 1 - total_run += props.custom_first_last_tread_run[0] - if props.custom_first_last_tread_run[1] is not None: # May be 0 though - default_rises -= 1 - total_run += props.custom_first_last_tread_run[1] - - total_run += props.tread_run * default_rises - return total_run - - @staticmethod - def _get_first_riser_height(props) -> float: - """Calculate the height of the first riser.""" - return props.height / (props.number_of_treads + 1) - - def get_dimension_matrix_total_length_target(self, props) -> Matrix: - return self.compose_gizmo_matrix(V_(0, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET), (1, 0, 0)) - - def get_dimension_matrix_height(self, props) -> Matrix: - total_run = self._get_stair_total_run(props) - return self.compose_gizmo_matrix(V_(total_run + self.GIZMO_OFFSET, -self.GIZMO_OFFSET, 0), (0, 0, 1)) - - def get_dimension_matrix_width(self, props) -> Matrix: - return self.compose_gizmo_matrix(V_(self.GIZMO_OFFSET, 0, -self.GIZMO_OFFSET), (0, 1, 0)) - - def get_dimension_matrix_tread_run(self, props) -> Matrix: - """Position depends on custom_tread_lock state.""" - riser_height = self._get_first_riser_height(props) - if props.custom_tread_lock: - x_offset = 0 - z_offset = riser_height - else: - x_offset = props.custom_first_last_tread_run[0] - z_offset = riser_height * 2 - return self.compose_gizmo_matrix(V_(x_offset, 0, z_offset), (1, 0, 0)) - - def get_dimension_matrix_custom_first_tread_run(self, props) -> Matrix: - riser_height = self._get_first_riser_height(props) - return self.compose_gizmo_matrix(V_(0, 0, riser_height), (1, 0, 0)) - - def get_dimension_matrix_custom_last_tread_run(self, props) -> Matrix: - total_run = self._get_stair_total_run(props) - x_offset = total_run - props.custom_first_last_tread_run[1] - return self.compose_gizmo_matrix(V_(x_offset, 0, props.height), (1, 0, 0)) - - def get_dimension_matrix_nosing_length(self, props) -> Matrix: - riser_height = self._get_first_riser_height(props) - return self.compose_gizmo_matrix(V_(0, props.width / 2, riser_height), (-1, 0, 0)) - - def get_dimension_matrix_tread_depth(self, props) -> Matrix: - riser_height = self._get_first_riser_height(props) - return self.compose_gizmo_matrix(V_(0, 0, riser_height), (0, 0, -1)) - - def get_dimension_matrix_riser_height(self, props) -> Matrix: - return self.compose_gizmo_matrix(V_(props.tread_run, props.width, 0), (0, 0, 1)) - - def get_dimension_matrix_nosing_depth(self, props) -> Matrix: - riser_height = self._get_first_riser_height(props) - return self.compose_gizmo_matrix(V_(-props.nosing_length, props.width / 2, riser_height), (0, 0, -1)) - - def get_dimension_matrix_base_slab_depth(self, props) -> Matrix: - return self.compose_gizmo_matrix(V_(0, props.width / 2, 0), (0, 0, -1)) - - def get_dimension_matrix_top_slab_depth(self, props) -> Matrix: - total_run = self._get_stair_total_run(props) - return self.compose_gizmo_matrix(V_(total_run, props.width / 2, props.height), (0, 0, -1)) - - def setup(self, context: bpy.types.Context) -> None: - self.setup_editing_gizmos(context) - self.setup_dimension_gizmos(context) - - prefs = tool.Blender.get_addon_preferences() - highlight_color = prefs.decorator_color_selected[:3] - - self.lock_gizmo = self.gizmos.new("VIEW3D_GT_lock") - self.lock_gizmo.use_draw_scale = False - self.lock_gizmo.color = self.COLOR_BLUE - self.lock_gizmo.color_highlight = highlight_color - self.lock_gizmo.alpha = 0.8 - self.lock_gizmo.prop_path = "BIMStairProperties.total_length_lock" - self.lock_gizmo.target_set_operator("bim.toggle_stair_total_length_lock") - - self.tread_lock_gizmo = self.gizmos.new("VIEW3D_GT_lock") - self.tread_lock_gizmo.use_draw_scale = False - self.tread_lock_gizmo.color = (1.0, 1.0, 1.0) - self.tread_lock_gizmo.color_highlight = highlight_color - self.tread_lock_gizmo.alpha = 0.8 - self.tread_lock_gizmo.prop_path = "BIMStairProperties.custom_tread_lock" - self.tread_lock_gizmo.target_set_operator("bim.toggle_stair_custom_tread_lock") - - self.plus_gizmo = self.gizmos.new("VIEW3D_GT_plus") - self.plus_gizmo.use_draw_scale = False - self.plus_gizmo.color = self.COLOR_GREEN - self.plus_gizmo.color_highlight = highlight_color - self.plus_gizmo.alpha = 0.8 - op = self.plus_gizmo.target_set_operator("bim.adjust_stair_treads") - op.increment = 1 - - self.minus_gizmo = self.gizmos.new("VIEW3D_GT_minus") - self.minus_gizmo.use_draw_scale = False - self.minus_gizmo.color = self.COLOR_RED - self.minus_gizmo.color_highlight = highlight_color - self.minus_gizmo.alpha = 0.8 - op = self.minus_gizmo.target_set_operator("bim.adjust_stair_treads") - op.increment = -1 - - def refresh(self, context: bpy.types.Context) -> None: - if not self.is_setup_complete(): - return - obj = context.active_object - if not obj: - return - - props = self.get_props(obj) - mw = obj.matrix_world + def _refresh_element_specific( + self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties" + ) -> None: + """Update stair-specific lock and tread count gizmos.""" billboard_rot = gizmo.get_billboard_rotation(context) - self.update_editing_gizmos(context, mw, props) self.update_lock_gizmo(mw, props, billboard_rot) self.update_tread_lock_gizmo(props) self.update_tread_count_gizmos(props) - self.update_dimension_gizmos(mw, props) - - def update_lock_gizmo(self, mw: Matrix, props, billboard_rot: Matrix) -> None: - if self.is_gizmo_hidden_by_modal(self.lock_gizmo): - self.lock_gizmo.hide = True - return + def update_lock_gizmo(self, mw: Matrix, props: "BIMStairProperties", billboard_rot: Matrix) -> None: + """Update lock gizmo visibility, color, and position.""" gizmo_prefs = self.get_gizmo_prefs() - self.lock_gizmo.hide = not props.is_editing or not gizmo_prefs.lock - - if self.lock_gizmo.hide: - return + if not self.update_gizmo_visibility(self.lock_gizmo, props.is_editing, gizmo_prefs.lock): + return # Hidden, skip positioning self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN - total_run = self._get_stair_total_run(props) + total_run = props.get_total_run() local_transform = ( - Matrix.Translation(Vector((total_run + 0.5, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET))) + Matrix.Translation(Vector((total_run + self.ICON_Z_OFFSET, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET))) @ billboard_rot @ Matrix.Scale(self.EDITING_ICON_SCALE, 4) ) self.lock_gizmo.matrix_basis = mw @ local_transform - def update_tread_lock_gizmo(self, props) -> None: + def update_tread_lock_gizmo(self, props: "BIMStairProperties") -> None: """Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions.""" if not hasattr(self, "tread_lock_gizmo"): return - - if self.is_gizmo_hidden_by_modal(self.tread_lock_gizmo): - self.tread_lock_gizmo.hide = True - return - gizmo_prefs = self.get_gizmo_prefs() - self.tread_lock_gizmo.hide = not props.is_editing or not gizmo_prefs.lock + self.update_gizmo_visibility(self.tread_lock_gizmo, props.is_editing, gizmo_prefs.lock) - def update_tread_count_gizmos(self, props) -> None: + def update_tread_count_gizmos(self, props: "BIMStairProperties") -> None: """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) + # 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 + ) - plus_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.plus_gizmo) - minus_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.minus_gizmo) - - if plus_hidden_by_modal: - self.plus_gizmo.hide = True - else: - gizmo_prefs = self.get_gizmo_prefs() - self.plus_gizmo.hide = not props.is_editing or not gizmo_prefs.plus - - if minus_hidden_by_modal: - self.minus_gizmo.hide = True - else: - gizmo_prefs = self.get_gizmo_prefs() - self.minus_gizmo.hide = not props.is_editing or props.number_of_treads <= 1 or not gizmo_prefs.minus - - def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props) -> None: + def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props: "BIMStairProperties") -> None: """Update dimension gizmo positions based on camera view direction.""" viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw) billboard_rot = gizmo.get_billboard_rotation(context) - total_run = self._get_stair_total_run(props) - riser_height = self._get_first_riser_height(props) + total_run = props.get_total_run() + riser_height = props.get_riser_height() self._update_overall_dimension_gizmos(mw, props, viewing_from_negative_y, viewing_from_negative_x, total_run) self._update_tread_dimension_gizmos(mw, props, viewing_from_negative_y, total_run, riser_height) @@ -774,57 +651,37 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): self._update_editing_icon_positions(mw, props, viewing_from_negative_y, billboard_rot) def _update_overall_dimension_gizmos( - self, mw: Matrix, props, viewing_from_negative_y: bool, viewing_from_negative_x: bool, total_run: float + self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, viewing_from_negative_x: bool, total_run: float ) -> None: """Update overall dimension gizmos (total_length, width, height).""" - if gizmo := self.get_dimension_gizmo_if_visible("total_length_target"): - y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True) - gizmo.matrix_basis = mw @ self.compose_gizmo_matrix( - V_(0, y_pos, -self.GIZMO_OFFSET), (1, 0, 0) - ) + y_pos_offset = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True) + x_pos = total_run + self.GIZMO_OFFSET if viewing_from_negative_x else -self.GIZMO_OFFSET - if gizmo := self.get_dimension_gizmo_if_visible("width"): - x_pos = total_run + self.GIZMO_OFFSET if viewing_from_negative_x else -self.GIZMO_OFFSET - gizmo.matrix_basis = mw @ self.compose_gizmo_matrix( - V_(x_pos, 0, -self.GIZMO_OFFSET), (0, 1, 0) - ) - - if gizmo := self.get_dimension_gizmo_if_visible("height"): - y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True) - gizmo.matrix_basis = mw @ self.compose_gizmo_matrix( - V_(total_run + self.GIZMO_OFFSET, y_pos, 0), (0, 0, 1) - ) + self.set_dimension_gizmo_position("total_length_target", mw, V_(0, y_pos_offset, -self.GIZMO_OFFSET), (1, 0, 0)) + self.set_dimension_gizmo_position("width", mw, V_(x_pos, 0, -self.GIZMO_OFFSET), (0, 1, 0)) + self.set_dimension_gizmo_position("height", mw, V_(total_run + self.GIZMO_OFFSET, y_pos_offset, 0), (0, 0, 1)) def _update_tread_dimension_gizmos( - self, mw: Matrix, props, viewing_from_negative_y: bool, total_run: float, riser_height: float + self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, total_run: float, riser_height: float ) -> None: """Update tread-related dimension gizmos (tread_run, custom first/last tread).""" - if gizmo := self.get_dimension_gizmo_if_visible("tread_run"): - y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False) - if props.custom_tread_lock: - x_offset, z_offset = 0, riser_height - else: - x_offset = props.custom_first_last_tread_run[0] - z_offset = riser_height * 2 - gizmo.matrix_basis = mw @ self.compose_gizmo_matrix( - V_(x_offset, y_pos, z_offset), (1, 0, 0) - ) + y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False) - if gizmo := self.get_dimension_gizmo_if_visible("custom_first_tread_run"): - y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False) - gizmo.matrix_basis = mw @ self.compose_gizmo_matrix( - V_(0, y_pos, riser_height), (1, 0, 0) - ) + # tread_run position depends on custom_tread_lock state + if props.custom_tread_lock: + tread_x, tread_z = 0, riser_height + else: + tread_x = props.custom_first_last_tread_run[0] + tread_z = riser_height * 2 + self.set_dimension_gizmo_position("tread_run", mw, V_(tread_x, y_pos, tread_z), (1, 0, 0)) - if gizmo := self.get_dimension_gizmo_if_visible("custom_last_tread_run"): - y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False) - x_offset = total_run - props.custom_first_last_tread_run[1] - gizmo.matrix_basis = mw @ self.compose_gizmo_matrix( - V_(x_offset, y_pos, props.height), (1, 0, 0) - ) + self.set_dimension_gizmo_position("custom_first_tread_run", mw, V_(0, y_pos, riser_height), (1, 0, 0)) + + last_x = total_run - props.custom_first_last_tread_run[1] + self.set_dimension_gizmo_position("custom_last_tread_run", mw, V_(last_x, y_pos, props.height), (1, 0, 0)) def _update_detail_dimension_gizmos( - self, mw: Matrix, props, viewing_from_negative_y: bool, riser_height: float + self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, riser_height: float ) -> None: """Update detail dimension gizmos (nosing, tread depth, riser height).""" y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False) @@ -835,25 +692,25 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): self.set_dimension_gizmo_position("nosing_depth", mw, V_(-props.nosing_length, props.width / 2, riser_height), (0, 0, -1)) def _update_lock_gizmo_position( - self, mw: Matrix, props, viewing_from_negative_y: bool, billboard_rot: Matrix, total_run: float + self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix, total_run: float ) -> None: """Update lock gizmo position based on Y view direction.""" y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True) self.set_icon_gizmo_position( - "lock_gizmo", mw, total_run + 0.5, y_pos, -self.GIZMO_OFFSET, billboard_rot, scale=self.EDITING_ICON_SCALE + "lock_gizmo", mw, total_run + self.ICON_Z_OFFSET, y_pos, -self.GIZMO_OFFSET, billboard_rot, scale=self.EDITING_ICON_SCALE ) - def _update_editing_icon_positions(self, mw, props, viewing_from_negative_y, billboard_rot): + def _update_editing_icon_positions(self, mw: Matrix, props: "BIMStairProperties", viewing_from_negative_y: bool, billboard_rot: Matrix) -> None: """Update editing icon positions, flipping Y based on viewing angle.""" if not props.is_editing: return - icon_z = props.height + 0.5 - y_pos = -self.GIZMO_OFFSET if viewing_from_negative_y else props.width + self.GIZMO_OFFSET + icon_z = props.height + self.ICON_Z_OFFSET + y_pos = self.get_icon_y_for_view(props, viewing_from_negative_y) self.set_icon_gizmo_position("validate_gizmo", mw, 0, y_pos, icon_z, billboard_rot) self.set_icon_gizmo_position("cancel_gizmo", mw, self.ICON_CANCEL_X, y_pos, icon_z, billboard_rot) - self.set_icon_gizmo_position("cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=0.3) + self.set_icon_gizmo_position("cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=self.ICON_CYCLE_SCALE) self.set_icon_gizmo_position( "tread_lock_gizmo", mw, self.ICON_TREAD_LOCK_X, y_pos, icon_z - self.EDITING_ICON_SCALE / 2, billboard_rot, scale=self.EDITING_ICON_SCALE diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index 3cf37e91ca..ad7de65556 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -31,9 +31,6 @@ from bonsai.bim.module.model.data import ( RailingData, RoofData, ) -from bonsai.bim.module.model.stair import regenerate_stair_mesh -from bonsai.bim.module.model.railing import update_railing_modifier_bmesh -from bonsai.bim.module.model.roof import update_roof_modifier_bmesh from collections.abc import Iterable from typing import Any, TYPE_CHECKING @@ -307,8 +304,6 @@ class BIM_PT_stair(bpy.types.Panel): row = self.layout.row(align=True) draw_stair_properties(self.layout, props) - - regenerate_stair_mesh(obj) else: calculated_params = StairData.data["calculated_params"] row.operator("bim.enable_editing_stair", icon="GREASEPENCIL", text="") @@ -565,9 +560,6 @@ class BIM_PT_railing(bpy.types.Panel): row.operator("bim.cancel_editing_railing", icon="CANCEL", text="") draw_railing_properties(self.layout, props) - - update_railing_modifier_bmesh(context) - elif props.is_editing_path: row.operator("bim.finish_editing_railing_path", icon="CHECKMARK", text="") row.operator("bim.cancel_editing_railing_path", icon="CANCEL", text="") @@ -623,8 +615,6 @@ class BIM_PT_roof(bpy.types.Panel): row.operator("bim.cancel_editing_roof", icon="CANCEL", text="") draw_roof_properties(self.layout, props) - - update_roof_modifier_bmesh(obj) elif props.is_editing_path: row.operator("bim.finish_editing_roof_path", icon="CHECKMARK", text="") row.operator("bim.cancel_editing_roof_path", icon="CANCEL", text="") diff --git a/src/bonsai/bonsai/bim/module/model/window.py b/src/bonsai/bonsai/bim/module/model/window.py index d899e12c7c..274d4288d9 100644 --- a/src/bonsai/bonsai/bim/module/model/window.py +++ b/src/bonsai/bonsai/bim/module/model/window.py @@ -39,47 +39,14 @@ import ifcopenshell.util.shape_builder import ifcopenshell.util.unit from bmesh.types import BMVert from mathutils import Vector, Matrix -from typing import get_args +from typing import get_args, TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.model.prop import BIMWindowProperties V_ = tool.Blender.V_ - -# Window type visibility helpers for dimension gizmos -_MULLION_TYPES = frozenset(( - "DOUBLE_PANEL_VERTICAL", - "TRIPLE_PANEL_BOTTOM", - "TRIPLE_PANEL_TOP", - "TRIPLE_PANEL_LEFT", - "TRIPLE_PANEL_RIGHT", - "TRIPLE_PANEL_VERTICAL", -)) -_TRANSOM_TYPES = frozenset(( - "DOUBLE_PANEL_HORIZONTAL", - "TRIPLE_PANEL_BOTTOM", - "TRIPLE_PANEL_TOP", - "TRIPLE_PANEL_LEFT", - "TRIPLE_PANEL_RIGHT", - "TRIPLE_PANEL_HORIZONTAL", -)) - - -def _has_mullion(props) -> bool: - """Check if the window type uses mullions (vertical dividers).""" - return props.window_type in _MULLION_TYPES - - -def _has_second_mullion(props) -> bool: - """Check if the window type uses a second mullion.""" - return props.window_type == "TRIPLE_PANEL_VERTICAL" - - -def _has_transom(props) -> bool: - """Check if the window type uses transoms (horizontal dividers).""" - return props.window_type in _TRANSOM_TYPES - - -def _has_second_transom(props) -> bool: - """Check if the window type uses a second transom.""" - return props.window_type == "TRIPLE_PANEL_HORIZONTAL" +# Shorthand for gizmo offset constants used in DimensionGizmoConfig lambdas +_G = gizmo.BaseParametricGizmoGroup def update_window_modifier_representation(context: bpy.types.Context) -> None: @@ -488,7 +455,7 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Add Bonsai parametric window to the active IFC element" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: obj = context.active_object assert obj element = tool.Ifc.get_entity(obj) @@ -522,7 +489,7 @@ class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Cancel editing and revert window parameters to their previous values" bl_options = {"REGISTER"} - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: obj = context.active_object assert obj element = tool.Ifc.get_entity(obj) @@ -551,7 +518,7 @@ class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Apply changes and finish editing window parameters" bl_options = {"REGISTER"} - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: obj = context.active_object assert obj element = tool.Ifc.get_entity(obj) @@ -584,7 +551,7 @@ class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Enter edit mode to modify window parameters interactively" bl_options = {"REGISTER"} - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: obj = context.active_object assert obj props = tool.Model.get_window_props(obj) @@ -607,7 +574,7 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Remove Window" bl_options = {"REGISTER"} - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: # noqa: ARG002 obj = context.active_object assert obj element = tool.Ifc.get_entity(obj) @@ -621,49 +588,46 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator): - """Cycle through available window types.""" +class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin): + """Cycle through available window types. Shift+click to cycle in reverse.""" bl_idname = "bim.cycle_window_type" bl_label = "Cycle Window Type" bl_options = {"REGISTER", "UNDO"} - def _execute(self, context): - obj = tool.Blender.get_active_object() - if not obj: - return {"CANCELLED"} + element_checker = "is_window" + props_getter = "get_window_props" + type_literal = tool.Model.WindowType + type_attr = "window_type" - element = tool.Ifc.get_entity(obj) - if not element or not tool.Blender.Modifier.is_window(element): - return {"CANCELLED"} - - props = tool.Model.get_window_props(obj) - window_types = list(get_args(tool.Model.WindowType)) - current_index = window_types.index(props.window_type) if props.window_type in window_types else 0 - next_index = (current_index + 1) % len(window_types) - props.window_type = window_types[next_index] - - return {"FINISHED"} + def _execute(self, context: bpy.types.Context) -> set[str]: + return self._cycle_type(context) -def _compute_frame_depth(props) -> float: - """Get the first panel's frame depth value.""" - return props.frame_depth[0] +# Frame accessor factory - creates callbacks that delegate to BIMWindowProperties methods +def _make_frame_accessors( + attr_name: str, panel_index: int +) -> tuple["collections.abc.Callable[[BIMWindowProperties], float]", "collections.abc.Callable[[BIMWindowProperties, float], None]"]: + """Create compute/apply callbacks for frame properties at a specific panel index. + + Args: + attr_name: Property name ("frame_depth" or "frame_thickness") + panel_index: Panel index (0, 1, or 2) + + Returns: + Tuple of (compute_fn, apply_fn) that delegate to BIMWindowProperties methods + """ + return ( + lambda props: props.get_frame_value(attr_name, panel_index), + lambda props, value: props.set_frame_value(attr_name, panel_index, value), + ) -def _apply_frame_depth(props, value: float) -> None: - """Apply a new frame depth value to the first panel, preserving other panels.""" - props.frame_depth = (max(0.0, value),) + tuple(props.frame_depth[1:]) - - -def _compute_frame_thickness(props) -> float: - """Get the first panel's frame thickness value.""" - return props.frame_thickness[0] - - -def _apply_frame_thickness(props, value: float) -> None: - """Apply a new frame thickness value to the first panel, preserving other panels.""" - props.frame_thickness = (max(0.0, value),) + tuple(props.frame_thickness[1:]) +_frame_accessors = { + (attr, idx): _make_frame_accessors(attr, idx) + for attr in ("frame_depth", "frame_thickness") + for idx in range(3) +} class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): @@ -678,180 +642,147 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): cancel_editing_operator = "bim.cancel_editing_window" cycle_type_operator = "bim.cycle_window_type" + # matrix_position lambdas replace the get_dimension_matrix_* methods dimension_gizmo_props = [ - DimensionGizmoConfig(attr_name="overall_width", axis=(1, 0, 0), min_value=0.01, text_offset_sign=-1), - DimensionGizmoConfig(attr_name="overall_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start"), - DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0)), - DimensionGizmoConfig(attr_name="lining_depth", axis=(0, 1, 0)), - DimensionGizmoConfig(attr_name="lining_thickness", axis=(1, 0, 0)), - DimensionGizmoConfig(attr_name="lining_to_panel_offset_x", axis=(1, 0, 0)), - DimensionGizmoConfig(attr_name="lining_to_panel_offset_y", axis=(0, 1, 0), min_value=-10.0), DimensionGizmoConfig( - attr_name="frame_depth", - axis=(0, -1, 0), - compute_value=_compute_frame_depth, - apply_value=_apply_frame_depth, + attr_name="overall_width", axis=(1, 0, 0), min_value=0.01, text_offset_sign=-1, + matrix_position=lambda p: V_(0, p.lining_offset - _G.GIZMO_OFFSET, -_G.GIZMO_OFFSET), ), DimensionGizmoConfig( - attr_name="frame_thickness", - axis=(1, 0, 0), - compute_value=_compute_frame_thickness, - apply_value=_apply_frame_thickness, + attr_name="overall_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start", + matrix_position=lambda p: V_(p.overall_width + _G.GIZMO_OFFSET, p.lining_offset - _G.GIZMO_OFFSET, 0), ), - DimensionGizmoConfig(attr_name="mullion_thickness", axis=(1, 0, 0), delta_scale=2.0, visibility_condition=_has_mullion), - DimensionGizmoConfig(attr_name="first_mullion_offset", axis=(1, 0, 0), visibility_condition=_has_mullion), - DimensionGizmoConfig(attr_name="second_mullion_offset", axis=(1, 0, 0), visibility_condition=_has_second_mullion), - DimensionGizmoConfig(attr_name="transom_thickness", axis=(0, 0, 1), delta_scale=2.0, visibility_condition=_has_transom), - DimensionGizmoConfig(attr_name="first_transom_offset", axis=(0, 0, 1), visibility_condition=_has_transom), - DimensionGizmoConfig(attr_name="second_transom_offset", axis=(0, 0, 1), visibility_condition=_has_second_transom), + DimensionGizmoConfig( + attr_name="lining_depth", axis=(0, 1, 0), + matrix_position=lambda p: V_(p.overall_width / 2, p.lining_offset, p.overall_height), + ), + DimensionGizmoConfig( + attr_name="lining_thickness", axis=(1, 0, 0), + matrix_position=lambda p: V_(0, p.lining_depth / 2 + p.lining_offset, p.overall_height / 2), + ), + DimensionGizmoConfig( + attr_name="lining_to_panel_offset_x", axis=(1, 0, 0), + matrix_position=lambda p: V_( + 0, + p.get_lining_to_panel_offset_y_full() + p.frame_depth[0] + p.lining_offset, + p.lining_to_panel_offset_x + ), + ), + DimensionGizmoConfig( + attr_name="lining_to_panel_offset_y", axis=(0, 1, 0), min_value=-10.0, + matrix_position=lambda p: V_( + p.overall_width - p.lining_to_panel_offset_x, + p.lining_depth + p.lining_offset, + p.lining_to_panel_offset_x + ), + ), + DimensionGizmoConfig( + attr_name="frame_depth", axis=(0, -1, 0), + compute_value=_frame_accessors[("frame_depth", 0)][0], + apply_value=_frame_accessors[("frame_depth", 0)][1], + matrix_position=lambda p: p.get_frame_position(0, is_depth=True), + ), + DimensionGizmoConfig( + attr_name="frame_thickness", axis=(1, 0, 0), + compute_value=_frame_accessors[("frame_thickness", 0)][0], + apply_value=_frame_accessors[("frame_thickness", 0)][1], + matrix_position=lambda p: p.get_frame_position(0, is_depth=False), + ), + DimensionGizmoConfig( + attr_name="second_frame_depth", axis=(0, -1, 0), + compute_value=_frame_accessors[("frame_depth", 1)][0], + apply_value=_frame_accessors[("frame_depth", 1)][1], + visibility_condition=lambda p: p.has_second_panel(), + matrix_position=lambda p: p.get_frame_position(1, is_depth=True), + ), + DimensionGizmoConfig( + attr_name="second_frame_thickness", axis=(1, 0, 0), + compute_value=_frame_accessors[("frame_thickness", 1)][0], + apply_value=_frame_accessors[("frame_thickness", 1)][1], + visibility_condition=lambda p: p.has_second_panel(), + matrix_position=lambda p: p.get_frame_position(1, is_depth=False), + ), + DimensionGizmoConfig( + attr_name="third_frame_depth", axis=(0, -1, 0), + compute_value=_frame_accessors[("frame_depth", 2)][0], + apply_value=_frame_accessors[("frame_depth", 2)][1], + visibility_condition=lambda p: p.has_third_panel(), + matrix_position=lambda p: p.get_frame_position(2, is_depth=True), + ), + DimensionGizmoConfig( + attr_name="third_frame_thickness", axis=(1, 0, 0), + compute_value=_frame_accessors[("frame_thickness", 2)][0], + apply_value=_frame_accessors[("frame_thickness", 2)][1], + visibility_condition=lambda p: p.has_third_panel(), + matrix_position=lambda p: p.get_frame_position(2, is_depth=False), + ), + DimensionGizmoConfig( + attr_name="mullion_thickness", axis=(1, 0, 0), delta_scale=2.0, + visibility_condition=lambda p: p.has_mullion(), + matrix_position=lambda p: V_( + p.first_mullion_offset - p.mullion_thickness / 2, + p.lining_offset, + p.overall_height / 2 + 3 * _G.GIZMO_STACK_OFFSET + ), + ), + DimensionGizmoConfig( + attr_name="first_mullion_offset", axis=(1, 0, 0), + visibility_condition=lambda p: p.has_mullion(), + matrix_position=lambda p: V_(0, p.lining_offset, p.overall_height / 2 + _G.GIZMO_STACK_OFFSET), + ), + DimensionGizmoConfig( + attr_name="second_mullion_offset", axis=(1, 0, 0), + visibility_condition=lambda p: p.has_second_mullion(), + matrix_position=lambda p: V_(0, p.lining_offset, p.overall_height / 2 + 2 * _G.GIZMO_STACK_OFFSET), + ), + DimensionGizmoConfig( + attr_name="transom_thickness", axis=(0, 0, 1), delta_scale=2.0, + visibility_condition=lambda p: p.has_transom(), + matrix_position=lambda p: V_( + p.overall_width / 2 + 2 * _G.GIZMO_STACK_OFFSET, + p.lining_offset, + p.first_transom_offset - p.transom_thickness / 2 + ), + ), + DimensionGizmoConfig( + attr_name="first_transom_offset", axis=(0, 0, 1), + visibility_condition=lambda p: p.has_transom(), + matrix_position=lambda p: V_(p.overall_width / 2, p.lining_offset, 0), + ), + DimensionGizmoConfig( + attr_name="second_transom_offset", axis=(0, 0, 1), + visibility_condition=lambda p: p.has_second_transom(), + matrix_position=lambda p: V_(p.overall_width / 2 + _G.GIZMO_STACK_OFFSET, p.lining_offset, 0), + ), + # lining_offset is handled specially in _update_dimension_gizmo_positions due to negative value support + DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0), ] + props_getter = "get_window_props" + gizmo_pref_name = "window" + @classmethod - def is_element_type(cls, element) -> bool: + def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool: return tool.Blender.Modifier.is_window(element) - def get_props(self, obj: bpy.types.Object): - return tool.Model.get_window_props(obj) - - def get_gizmo_prefs(self): - prefs = tool.Blender.get_addon_preferences() - return prefs.gizmos.window - - def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: - """Position icons beyond the furthest geometry extent based on view direction.""" - obj = context.active_object - if not obj: - return self.ICON_Y_OFFSET - props = self.get_props(obj) + def get_icon_y_extent(self, props: "BIMWindowProperties") -> tuple[float, float]: + """Get Y extents for window icon positioning. + Window geometry can extend asymmetrically in +Y and -Y directions + depending on lining_offset (which can be negative). + """ furthest_positive_y = ( max(0, props.lining_offset) + props.lining_depth + props.lining_to_panel_offset_y + + 2 * self.GIZMO_OFFSET ) - furthest_negative_y = min(0, props.lining_offset) + furthest_negative_y = abs(min(0, props.lining_offset)) + 2 * self.GIZMO_OFFSET + return (furthest_positive_y, furthest_negative_y) - viewing_from_negative_y, _ = self.get_local_view_direction(context, mw) - if viewing_from_negative_y: - return furthest_negative_y - 2 * self.GIZMO_OFFSET - return furthest_positive_y + 2 * self.GIZMO_OFFSET + # Window uses base class setup() and refresh() - no element-specific gizmos needed - def get_dimension_matrix_lining_offset(self, props) -> Matrix: - return self.compose_gizmo_matrix(V_(0, 0, 0), (0, 1, 0)) - - def get_dimension_matrix_lining_depth(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(props.overall_width / 2, props.lining_offset, props.overall_height), (0, 1, 0) - ) - - def get_dimension_matrix_lining_thickness(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(0, props.lining_depth / 2 + props.lining_offset, props.overall_height / 2), (1, 0, 0) - ) - - @staticmethod - def _get_lining_to_panel_offset_y_full(props) -> float: - """Get the full Y offset for lining-to-panel positioning.""" - return (props.lining_depth - props.frame_depth[0]) + props.lining_to_panel_offset_y - - def get_dimension_matrix_lining_to_panel_offset_x(self, props) -> Matrix: - y_full = self._get_lining_to_panel_offset_y_full(props) - return self.compose_gizmo_matrix( - V_(0, y_full + props.frame_depth[0] + props.lining_offset, props.lining_to_panel_offset_x), (1, 0, 0) - ) - - def get_dimension_matrix_lining_to_panel_offset_y(self, props) -> Matrix: - y_start = props.lining_depth + props.lining_offset - return self.compose_gizmo_matrix( - V_(props.overall_width - props.lining_to_panel_offset_x, y_start, props.lining_to_panel_offset_x), - (0, 1, 0), - ) - - def get_dimension_matrix_frame_depth(self, props) -> Matrix: - y_full = self._get_lining_to_panel_offset_y_full(props) - y_start = y_full + props.frame_depth[0] + props.lining_offset - return self.compose_gizmo_matrix( - V_(props.lining_to_panel_offset_x, y_start, props.lining_to_panel_offset_x), (0, -1, 0) - ) - - def get_dimension_matrix_frame_thickness(self, props) -> Matrix: - y_full = self._get_lining_to_panel_offset_y_full(props) - y_pos = y_full + props.frame_depth[0] + props.lining_offset - return self.compose_gizmo_matrix( - V_(props.lining_to_panel_offset_x, y_pos, props.overall_height / 2), (1, 0, 0) - ) - - def get_dimension_matrix_mullion_thickness(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(props.first_mullion_offset - props.mullion_thickness / 2, props.lining_offset, props.overall_height / 2), - (1, 0, 0), - ) - - def get_dimension_matrix_first_mullion_offset(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(0, props.lining_offset, props.overall_height / 2), (1, 0, 0) - ) - - def get_dimension_matrix_second_mullion_offset(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(0, props.lining_offset, props.overall_height / 2 + 0.1), (1, 0, 0) - ) - - def get_dimension_matrix_transom_thickness(self, props) -> Matrix: - # Offset X when panels are horizontal to avoid overlap with mullion gizmo - x_pos = props.overall_width / 2 - 0.1 if _has_transom(props) else props.overall_width / 2 - return self.compose_gizmo_matrix( - V_(x_pos, props.lining_offset, props.first_transom_offset - props.transom_thickness / 2), - (0, 0, 1), - ) - - def get_dimension_matrix_first_transom_offset(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(props.overall_width / 2, props.lining_offset, 0), (0, 0, 1) - ) - - def get_dimension_matrix_second_transom_offset(self, props) -> Matrix: - return self.compose_gizmo_matrix( - V_(props.overall_width / 2 + 0.1, props.lining_offset, 0), (0, 0, 1) - ) - - def get_dimension_matrix_overall_width(self, props) -> Matrix: - """Position width dimension below the window.""" - return self.compose_gizmo_matrix( - V_(0, props.lining_offset - self.GIZMO_OFFSET, -self.GIZMO_OFFSET), (1, 0, 0) - ) - - def get_dimension_matrix_overall_height(self, props) -> Matrix: - """Position height dimension to the side of the window.""" - return self.compose_gizmo_matrix( - V_(props.overall_width + self.GIZMO_OFFSET, props.lining_offset - self.GIZMO_OFFSET, 0), (0, 0, 1) - ) - - def setup(self, context: bpy.types.Context) -> None: - self.setup_editing_gizmos(context) - self.setup_dimension_gizmos(context) - - def refresh(self, context: bpy.types.Context) -> None: - if not self.is_setup_complete(): - return - obj = context.active_object - if not obj: - return - - props = self.get_props(obj) - mw = obj.matrix_world - self.update_editing_gizmos(context, mw, props) - self.update_dimension_gizmos(mw, props) - - def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props) -> None: + def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props: "BIMWindowProperties") -> None: """Update dimension gizmo positions based on camera view direction.""" - viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw) - y_pos = self.get_lining_y_position_for_view(props, viewing_from_negative_y) - - self.set_dimension_gizmo_position("overall_width", mw, V_(0, y_pos, -self.GIZMO_OFFSET), (1, 0, 0)) - - if viewing_from_negative_x: - x_pos = -self.GIZMO_OFFSET - else: - x_pos = props.overall_width + self.GIZMO_OFFSET - self.set_dimension_gizmo_position("overall_height", mw, V_(x_pos, y_pos, 0), (0, 0, 1)) + # Window uses base implementation with default casing_offset=0 + self._update_view_dependent_dimensions(context, mw, props)