From c769c9e67c43efbc20f38b1f85976198db37f5c5 Mon Sep 17 00:00:00 2001 From: Gorgious Date: Mon, 1 Dec 2025 23:13:34 +0100 Subject: [PATCH] Update to gizmos system Added more gizmos for multi-panel windows and for the door transom. Support negative dimension values (lining offset for door and window) Fix railing, stair, and roof being regenerated during UI panel draw instead of on property change Various code quality changes and DRY improvements --- .../bonsai/bim/module/drawing/gizmos.py | 939 ++++++++++++++++-- .../bonsai/bim/module/model/__init__.py | 3 +- src/bonsai/bonsai/bim/module/model/door.py | 377 +++---- src/bonsai/bonsai/bim/module/model/prop.py | 508 +++++++--- src/bonsai/bonsai/bim/module/model/stair.py | 511 ++++------ src/bonsai/bonsai/bim/module/model/ui.py | 10 - src/bonsai/bonsai/bim/module/model/window.py | 401 ++++---- 7 files changed, 1747 insertions(+), 1002 deletions(-) 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)