From c828c1b08da1904230bf56210e9595c8aa0bd63d Mon Sep 17 00:00:00 2001 From: Gorgious Date: Thu, 27 Nov 2025 22:56:27 +0100 Subject: [PATCH] Refactor parametric element gizmos - Add gizmos for windows and stairs - Add BaseParametricGizmoGroup mixin for doors, windows, and stairs - Improve snapping with screen-space distance calculation - Add GizmoPlus, GizmoMinus, GizmoCycle icon gizmos - Add GizmoPropConfig dataclass for cleaner gizmo configuration --- src/bonsai/bonsai/bim/__init__.py | 5 + src/bonsai/bonsai/bim/gizmo.py | 825 +++++++++++++++--- .../bonsai/bim/module/model/__init__.py | 5 + src/bonsai/bonsai/bim/module/model/door.py | 317 +++---- src/bonsai/bonsai/bim/module/model/prop.py | 24 +- src/bonsai/bonsai/bim/module/model/stair.py | 311 ++++++- src/bonsai/bonsai/bim/module/model/window.py | 206 ++++- src/bonsai/bonsai/bim/ui.py | 101 ++- 8 files changed, 1464 insertions(+), 330 deletions(-) diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index f7adfb6319..98cbbbd3d8 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -149,6 +149,8 @@ classes = [ ui.BIM_UL_generic, ui.DocPreferences, ui.GizmoPreferencesDoor, # Register before GizmoPreferences + ui.GizmoPreferencesWindow, # Register before GizmoPreferences + ui.GizmoPreferencesStair, # Register before GizmoPreferences ui.GizmoPreferences, # ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below) # Tabs panel @@ -212,6 +214,9 @@ classes = [ gizmo.GizmoPen, gizmo.GizmoValidate, gizmo.GizmoCancel, + gizmo.GizmoPlus, + gizmo.GizmoMinus, + gizmo.GizmoCycle, ] for mod in modules.values(): diff --git a/src/bonsai/bonsai/bim/gizmo.py b/src/bonsai/bonsai/bim/gizmo.py index adf677683a..2f6cc2a08e 100644 --- a/src/bonsai/bonsai/bim/gizmo.py +++ b/src/bonsai/bonsai/bim/gizmo.py @@ -20,22 +20,50 @@ Shared gizmo components for reuse across modules. """ +__all__ = [ + # Dataclasses + "GizmoPropConfig", + # Functions + "set_snap_point", + "clear_snap_point", + "snap_to_mesh", + "generate_circle_vertices", + "create_circle_arc", + # Gizmo classes + "GizmoMovable", + "GizmoLock", + "GizmoArc", + "GizmoPen", + "GizmoValidate", + "GizmoCancel", + "GizmoPlus", + "GizmoMinus", + "GizmoCycle", + "GizmoArrow", + "GizmoCone", + # Mixin classes + "BaseParametricGizmoGroup", +] + +from typing import Any + import bpy import math -from typing import Optional -from mathutils import Vector +from dataclasses import dataclass +from mathutils import Vector, Matrix from mathutils.bvhtree import BVHTree from mathutils.geometry import intersect_line_line -from bpy_extras.view3d_utils import region_2d_to_vector_3d, region_2d_to_origin_3d +from bpy_extras.view3d_utils import region_2d_to_vector_3d, region_2d_to_origin_3d, location_3d_to_region_2d import gpu from gpu_extras.batch import batch_for_shader +import bonsai.tool as tool -SNAP_CIRCLE_SEGMENTS = 16 -SNAP_CIRCLE_RADIUS = 0.1 -SNAP_CIRCLE_COLOR = (1.0, 0.5, 0.0, 1.0) -SNAP_LINE_WIDTH = 3.0 -SNAP_MAX_RADIUS = 10.0 +SNAP_POINT_SIZE = 10.0 +SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0) +SNAP_MAX_RADIUS = 5.0 +SNAP_SCREEN_DISTANCE = 30 # Maximum screen-space distance for snapping (in pixels) +SNAP_WORLD_DISTANCE = 0.2 # Maximum world-space distance for snapping (in meters) ARROW_SHAFT_LENGTH = 0.8 ARROW_HEAD_LENGTH = 0.2 @@ -52,16 +80,38 @@ ARC_LINE_WIDTH = 0.015 PRECISION_MODE_MULTIPLIER = 0.1 +RAY_CAST_DISTANCE = 1000 # Distance to extend rays for intersection calculations +DEFAULT_POINT_SIZE = 1.0 # Default GPU point size + + +@dataclass +class GizmoPropConfig: + """Configuration for a gizmo property. + + Attributes: + attr_name: Name of the property attribute on the element's props + axis: Direction axis as (x, y, z) tuple, determines gizmo color and direction. + Color convention follows Blender's standard axis colors: + - X axis (1, 0, 0) or (-1, 0, 0) -> Red + - Y axis (0, 1, 0) or (0, -1, 0) -> Green + - Z axis (0, 0, 1) or (0, 0, -1) -> Blue + invert_delta: If True, inverts the drag direction for this gizmo + """ + + attr_name: str + axis: tuple[int, int, int] + invert_delta: bool = False + class SnapManager: """Manages snap point visualization and mesh snapping.""" def __init__(self): - self._snap_point: Optional[tuple[float, float, float]] = None + self._snap_point: tuple[float, float, float] | Vector | None = None self._draw_handler = None self._shader = None - def set_snap_point(self, point: Optional[tuple[float, float, float]]) -> None: + def set_snap_point(self, point: tuple[float, float, float] | Vector | None) -> None: """Set snap point and register draw handler if needed.""" self._snap_point = point if self._draw_handler is None and point is not None: @@ -77,7 +127,7 @@ class SnapManager: self._redraw_viewport() def _draw(self) -> None: - """Draw snap point circles.""" + """Draw snap point as a dot.""" if self._snap_point is None: return @@ -85,15 +135,13 @@ class SnapManager: self._shader = gpu.shader.from_builtin("UNIFORM_COLOR") self._shader.bind() - self._shader.uniform_float("color", SNAP_CIRCLE_COLOR) - gpu.state.line_width_set(SNAP_LINE_WIDTH) + self._shader.uniform_float("color", SNAP_POINT_COLOR) + gpu.state.point_size_set(SNAP_POINT_SIZE) - for plane in ("XY", "XZ", "YZ"): - vertices = generate_circle_vertices(self._snap_point, SNAP_CIRCLE_RADIUS, SNAP_CIRCLE_SEGMENTS, plane) - batch = batch_for_shader(self._shader, "LINE_STRIP", {"pos": vertices}) - batch.draw(self._shader) + batch = batch_for_shader(self._shader, "POINTS", {"pos": [self._snap_point]}) + batch.draw(self._shader) - gpu.state.line_width_set(1.0) + gpu.state.point_size_set(DEFAULT_POINT_SIZE) @staticmethod def _redraw_viewport() -> None: @@ -102,9 +150,113 @@ class SnapManager: if area.type == "VIEW_3D": area.tag_redraw() + @staticmethod + def _calc_snap_distance( + point_3d: Vector, + location: Vector, + mouse_coords: tuple[float, float] | None, + region: bpy.types.Region | None, + rv3d: bpy.types.RegionView3D | None, + ) -> float: + """Calculate distance - screen-space if mouse coords available, else world-space.""" + if mouse_coords is not None and region is not None and rv3d is not None: + point_2d = location_3d_to_region_2d(region, rv3d, point_3d) + if point_2d is not None: + return (Vector(mouse_coords) - point_2d).length + return float("inf") + return (point_3d - location).length + + @staticmethod + def _find_closest_vertex( + world_vertices: list[Vector], + location: Vector, + mouse_coords: tuple[float, float] | None, + region: bpy.types.Region | None, + rv3d: bpy.types.RegionView3D | None, + closest_point: Vector | None, + closest_distance: float, + ) -> tuple[Vector | None, float]: + """Find the closest vertex to snap to.""" + for v_co in world_vertices: + dist = SnapManager._calc_snap_distance(v_co, location, mouse_coords, region, rv3d) + if dist < closest_distance: + closest_distance = dist + closest_point = v_co + return closest_point, closest_distance + + @staticmethod + def _find_closest_edge_point( + mesh: bpy.types.Mesh, + world_vertices: list[Vector], + location: Vector, + mouse_coords: tuple[float, float] | None, + region: bpy.types.Region | None, + rv3d: bpy.types.RegionView3D | None, + closest_point: Vector | None, + closest_distance: float, + ) -> tuple[Vector | None, float]: + """Find the closest point on an edge to snap to.""" + for edge in mesh.edges: + v1 = world_vertices[edge.vertices[0]] + v2 = world_vertices[edge.vertices[1]] + + edge_vec = v2 - v1 + edge_len_sq = edge_vec.length_squared + + if edge_len_sq > 0: + t = max(0, min(1, (location - v1).dot(edge_vec) / edge_len_sq)) + closest_on_edge = v1 + t * edge_vec + dist = SnapManager._calc_snap_distance(closest_on_edge, location, mouse_coords, region, rv3d) + + if dist < closest_distance: + closest_distance = dist + closest_point = closest_on_edge + return closest_point, closest_distance + + @staticmethod + def _find_closest_face_point( + mesh: bpy.types.Mesh, + world_vertices: list[Vector], + location: Vector, + mouse_coords: tuple[float, float] | None, + region: bpy.types.Region | None, + rv3d: bpy.types.RegionView3D | None, + closest_point: Vector | None, + closest_distance: float, + ) -> tuple[Vector | None, float]: + """Find the closest point on a face to snap to.""" + bvh = BVHTree.FromPolygons(world_vertices, [p.vertices for p in mesh.polygons]) + nearest_loc, normal, index, dist = bvh.find_nearest(location) + + if nearest_loc: + screen_dist = SnapManager._calc_snap_distance(nearest_loc, location, mouse_coords, region, rv3d) + if screen_dist < closest_distance: + closest_distance = screen_dist + closest_point = nearest_loc + return closest_point, closest_distance + + @staticmethod + def _get_nearby_objects( + mesh_objects: list[bpy.types.Object], + location: Vector, + ) -> list[bpy.types.Object]: + """Filter objects to those within SNAP_MAX_RADIUS of location.""" + nearby_objects = [] + for obj in mesh_objects: + bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] + for corner in bbox_corners: + if (corner - location).length <= SNAP_MAX_RADIUS: + nearby_objects.append(obj) + break + return nearby_objects + @staticmethod def snap_to_mesh( - location: Vector, context: bpy.types.Context, axis_vector: Vector, active_obj: bpy.types.Object + location: Vector, + context: bpy.types.Context, + axis_vector: Vector, + active_obj: bpy.types.Object, + mouse_coords: tuple[float, float] | None = None, ) -> Vector: """Snap a location to the nearest mesh element if snapping is enabled. @@ -113,6 +265,7 @@ class SnapManager: context: The Blender context axis_vector: The axis direction of the gizmo active_obj: The active object to exclude from snapping + mouse_coords: Optional (x, y) mouse position in region coordinates for screen-space distance Returns: The snapped location or original location if snapping is disabled @@ -131,19 +284,16 @@ class SnapManager: if not mesh_objects: return location - nearby_objects = [] - for obj in mesh_objects: - bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] - - for corner in bbox_corners: - if (corner - location).length <= SNAP_MAX_RADIUS: - nearby_objects.append(obj) - break + nearby_objects = SnapManager._get_nearby_objects(mesh_objects, location) if not nearby_objects: return location - closest_point = None + region = context.region + rv3d = context.region_data + use_screen_distance = mouse_coords is not None and region is not None and rv3d is not None + + closest_point: Vector | None = None closest_distance = float("inf") for obj in nearby_objects: @@ -161,41 +311,25 @@ class SnapManager: world_vertices = [obj.matrix_world @ v.co for v in mesh.vertices] if "VERTEX" in snap_elements: - for v_co in world_vertices: - dist = (v_co - location).length - if dist < closest_distance: - closest_distance = dist - closest_point = v_co + closest_point, closest_distance = SnapManager._find_closest_vertex( + world_vertices, location, mouse_coords, region, rv3d, closest_point, closest_distance + ) if "EDGE" in snap_elements: - for edge in mesh.edges: - v1 = world_vertices[edge.vertices[0]] - v2 = world_vertices[edge.vertices[1]] - - edge_vec = v2 - v1 - edge_len_sq = edge_vec.length_squared - - if edge_len_sq > 0: - t = max(0, min(1, (location - v1).dot(edge_vec) / edge_len_sq)) - closest_on_edge = v1 + t * edge_vec - dist = (closest_on_edge - location).length - - if dist < closest_distance: - closest_distance = dist - closest_point = closest_on_edge + closest_point, closest_distance = SnapManager._find_closest_edge_point( + mesh, world_vertices, location, mouse_coords, region, rv3d, closest_point, closest_distance + ) if "FACE" in snap_elements: - bvh = BVHTree.FromPolygons(world_vertices, [p.vertices for p in mesh.polygons]) - - nearest_loc, normal, index, dist = bvh.find_nearest(location) - - if nearest_loc and dist < closest_distance: - closest_distance = dist - closest_point = nearest_loc + closest_point, closest_distance = SnapManager._find_closest_face_point( + mesh, world_vertices, location, mouse_coords, region, rv3d, closest_point, closest_distance + ) obj_eval.to_mesh_clear() - if closest_point and closest_distance < SNAP_MAX_RADIUS: + # Use screen-space threshold (pixels) or fall back to world-space + max_distance = SNAP_SCREEN_DISTANCE if use_screen_distance else SNAP_WORLD_DISTANCE + if closest_point and closest_distance < max_distance: return closest_point return location @@ -204,7 +338,7 @@ class SnapManager: _snap_manager = SnapManager() -def set_snap_point(point: Optional[tuple[float, float, float]]) -> None: +def set_snap_point(point: tuple[float, float, float] | Vector | None) -> None: _snap_manager.set_snap_point(point) @@ -213,13 +347,17 @@ def clear_snap_point() -> None: def snap_to_mesh( - location: Vector, context: bpy.types.Context, axis_vector: Vector, active_obj: bpy.types.Object + location: Vector, + context: bpy.types.Context, + axis_vector: Vector, + active_obj: bpy.types.Object, + mouse_coords: tuple[float, float] | None = None, ) -> Vector: - return _snap_manager.snap_to_mesh(location, context, axis_vector, active_obj) + return _snap_manager.snap_to_mesh(location, context, axis_vector, active_obj, mouse_coords) def generate_circle_vertices( - center: tuple[float, float, float], radius: float, segments: int, plane: str = "XY" + center: tuple[float, float, float] | Vector, radius: float, segments: int, plane: str = "XY" ) -> list[tuple[float, float, float]]: """Generate circle vertices in specified plane. @@ -248,32 +386,42 @@ def generate_circle_vertices( return vertices -def create_quarter_circle_arc( - radius: float = 1.0, segments: int = ARC_SEGMENTS, direction: str = "LEFT", line_width: float = ARC_LINE_WIDTH +def create_circle_arc( + radius: float = 1.0, + segments: int = ARC_SEGMENTS, + direction: str = "LEFT", + line_width: float = ARC_LINE_WIDTH, + angle_min: float = 0.0, + angle_max: float = 90.0, ) -> tuple[tuple[float, float, float], ...]: - """Create a quarter circle arc with cross-section thickness for visibility from all angles. + """Create a circle arc with cross-section thickness for visibility from all angles. Args: radius: Radius of the arc segments: Number of segments for smoothness - direction: 'LEFT' for counterclockwise (0 to 90°), 'RIGHT' for clockwise (0 to -90°) + direction: 'LEFT' for counterclockwise, 'RIGHT' for clockwise (mirrors along X) line_width: Width of the arc line in both perpendicular directions + angle_min: Start angle in degrees (default 0°) + angle_max: End angle in degrees (default 90°) Returns: Tuple of arc triangles for drawing geometry visible from all angles """ half_width = line_width / 2 + angle_min_rad = math.radians(angle_min) + angle_max_rad = math.radians(angle_max) + angle_range = angle_max_rad - angle_min_rad arc_points = [] if direction == "LEFT": for i in range(segments + 1): - angle = (math.pi / 2) * (i / segments) + angle = angle_min_rad + angle_range * (i / segments) x = radius * math.cos(angle) y = radius * math.sin(angle) arc_points.append((x, y)) else: for i in range(segments + 1): - angle = (math.pi / 2) * (i / segments) + angle = angle_min_rad + angle_range * (i / segments) x = -radius * math.cos(angle) y = radius * math.sin(angle) arc_points.append((x, y)) @@ -342,6 +490,7 @@ class GizmoMovable(bpy.types.Gizmo): "start_location", "active_obj", "initial_snap_state", + "invert_delta", ) def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set: @@ -377,16 +526,16 @@ class GizmoMovable(bpy.types.Gizmo): result = intersect_line_line( view_origin, - view_origin + view_direction * 1000, + view_origin + view_direction * RAY_CAST_DISTANCE, self.start_location, - self.start_location + axis_direction * 1000, + self.start_location + axis_direction * RAY_CAST_DISTANCE, ) current_3d = result[1] if result else self.start_location delta = (current_3d - self.start_location).dot(axis_direction) if tool_settings.use_snap and self.active_obj: - snapped_pos = snap_to_mesh(current_3d, context, axis_direction, self.active_obj) + snapped_pos = snap_to_mesh(current_3d, context, axis_direction, self.active_obj, current_coord) if snapped_pos != current_3d: delta = (snapped_pos - self.start_location).dot(axis_direction) set_snap_point(snapped_pos) @@ -398,6 +547,9 @@ class GizmoMovable(bpy.types.Gizmo): if event.shift: delta *= PRECISION_MODE_MULTIPLIER + if getattr(self, "invert_delta", False): + delta = -delta + if self.move_set_cb: self.move_set_cb(self.init_value + delta) @@ -431,60 +583,97 @@ class GizmoLock(bpy.types.Gizmo): ) tris_closed = ( - (0.16803650558, 0.18791499734, 0.0), - (-0.07805634290, 0.18791499734, 0.0), - (0.16803650558, 0.44701099396, 0.0), - (0.16803650558, 0.44701099396, 0.0), - (-0.07805634290, 0.18791499734, 0.0), - (-0.07805634290, 0.44701099396, 0.0), - (0.20165449381, 0.13603900373, 0.0), - (-0.11167433113, 0.13603900373, 0.0), - (0.20165449381, -0.44701099396, 0.0), - (0.20165449381, -0.44701099396, 0.0), - (-0.11167433113, 0.13603900373, 0.0), - (-0.11167433113, -0.44701099396, 0.0), - (-0.07805634290, 0.18791499734, 0.0), - (-0.39353451133, 0.18791499734, 0.0), - (-0.07805634290, 0.44701099396, 0.0), - (-0.07805634290, 0.44701099396, 0.0), - (-0.39353451133, 0.18791499734, 0.0), - (-0.39353451133, 0.30746498704, 0.0), - (-0.44701099396, 0.18791499734, 0.0), - (-0.44701099396, -0.04477182776, 0.0), - (-0.39353451133, 0.18791499734, 0.0), - (-0.39353451133, 0.18791499734, 0.0), - (-0.44701099396, -0.04477182776, 0.0), - (-0.39353451133, -0.04477182776, 0.0), + (-0.12838619947433472, 1.3143587112426758, 0.0), + (0.025773197412490845, 1.411454677581787, 0.0), + (-0.0144234299659729, 1.541273593902588, 0.0), + (-0.0144234299659729, 1.541273593902588, 0.0), + (0.025773197412490845, 1.411454677581787, 0.0), + (0.20782703161239624, 1.4184625148773193, 0.0), + (0.23792517185211182, 1.5509872436523438, 0.0), + (0.20782703161239624, 1.4184625148773193, 0.0), + (0.3689943850040436, 1.3335046768188477, 0.0), + (0.4613226056098938, 1.433225393295288, 0.0), + (0.3689943850040436, 1.3335046768188477, 0.0), + (0.4660903215408325, 1.1793451309204102, 0.0), + (0.5959094166755676, 1.2195416688919067, 0.0), + (0.4660903215408325, 1.1793451309204102, 0.0), + (0.47309836745262146, 0.997291088104248, 0.0), + (0.6056233048439026, 0.9671931266784668, 0.0), + (0.47309836745262146, 0.997291088104248, 0.0), + (0.3881405293941498, 0.8361238241195679, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (0.48786139488220215, 4.5077928945147505e-08, 0.0), + (0.48786139488220215, 0.7437955141067505, 0.0), + (-0.12838619947433472, 1.3143587112426758, 0.0), + (-0.0144234299659729, 1.541273593902588, 0.0), + (-0.22810709476470947, 1.406686782836914, 0.0), + (-0.0144234299659729, 1.541273593902588, 0.0), + (0.20782703161239624, 1.4184625148773193, 0.0), + (0.23792517185211182, 1.5509872436523438, 0.0), + (0.23792517185211182, 1.5509872436523438, 0.0), + (0.3689943850040436, 1.3335046768188477, 0.0), + (0.4613226056098938, 1.433225393295288, 0.0), + (0.4613226056098938, 1.433225393295288, 0.0), + (0.4660903215408325, 1.1793451309204102, 0.0), + (0.5959094166755676, 1.2195416688919067, 0.0), + (0.5959094166755676, 1.2195416688919067, 0.0), + (0.47309836745262146, 0.997291088104248, 0.0), + (0.6056233048439026, 0.9671931266784668, 0.0), + (0.6056233048439026, 0.9671931266784668, 0.0), + (0.3881405293941498, 0.8361238241195679, 0.0), + (0.48786142468452454, 0.74379563331604, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (-0.48786139488220215, 4.5077928945147505e-08, 0.0), + (0.48786139488220215, 4.5077928945147505e-08, 0.0), ) tris_open = ( - (0.16803650558, 0.18791499734, 0.0), - (-0.07805634290, 0.18791499734, 0.0), - (0.16803650558, 0.44701099396, 0.0), - (0.16803650558, 0.44701099396, 0.0), - (-0.07805634290, 0.18791499734, 0.0), - (-0.07805634290, 0.44701099396, 0.0), - (0.20165449381, 0.13603900373, 0.0), - (-0.11167433113, 0.13603900373, 0.0), - (0.20165449381, -0.44701099396, 0.0), - (0.20165449381, -0.44701099396, 0.0), - (-0.11167433113, 0.13603900373, 0.0), - (-0.11167433113, -0.44701099396, 0.0), - (0.16803650558, 0.44701099396, 0.0), - (-0.07805634290, 0.44701099396, 0.0), - (0.16803650558, 0.70610702038, 0.0), - (0.16803650558, 0.70610702038, 0.0), - (-0.07805634290, 0.44701099396, 0.0), - (-0.07805634290, 0.58656096458, 0.0), - (-0.11167433113, 0.44701099396, 0.0), - (-0.11167433113, 0.73432201147, 0.0), - (-0.07805634290, 0.44701099396, 0.0), - (-0.07805634290, 0.44701099396, 0.0), - (-0.11167433113, 0.73432201147, 0.0), - (-0.07805634290, 0.73432201147, 0.0), + (-0.3519617021083832, 0.7437955141067505, 0.0), + (-0.3048076927661896, 0.9197763204574585, 0.0), + (-0.4225003123283386, 0.9877263307571411, 0.0), + (-0.4225003123283386, 0.9877263307571411, 0.0), + (-0.3048076927661896, 0.9197763204574585, 0.0), + (-0.1759808510541916, 1.0486031770706177, 0.0), + (-0.24393069744110107, 1.1662957668304443, 0.0), + (-0.1759808510541916, 1.0486031770706177, 0.0), + (2.9078805141580233e-08, 1.0957571268081665, 0.0), + (2.9078805141580233e-08, 1.2316569089889526, 0.0), + (2.9078805141580233e-08, 1.0957571268081665, 0.0), + (0.1759808510541916, 1.0486031770706177, 0.0), + (0.243930846452713, 1.1662957668304443, 0.0), + (0.1759808510541916, 1.0486031770706177, 0.0), + (0.30480796098709106, 0.9197763204574585, 0.0), + (0.4225005805492401, 0.9877263307571411, 0.0), + (0.30480796098709106, 0.9197763204574585, 0.0), + (0.35196200013160706, 0.7437955141067505, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (0.48786139488220215, 4.5077928945147505e-08, 0.0), + (0.48786139488220215, 0.7437955141067505, 0.0), + (-0.3519617021083832, 0.7437955141067505, 0.0), + (-0.4225003123283386, 0.9877263307571411, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (-0.4225003123283386, 0.9877263307571411, 0.0), + (-0.1759808510541916, 1.0486031770706177, 0.0), + (-0.24393069744110107, 1.1662957668304443, 0.0), + (-0.24393069744110107, 1.1662957668304443, 0.0), + (2.9078805141580233e-08, 1.0957571268081665, 0.0), + (2.9078805141580233e-08, 1.2316569089889526, 0.0), + (2.9078805141580233e-08, 1.2316569089889526, 0.0), + (0.1759808510541916, 1.0486031770706177, 0.0), + (0.243930846452713, 1.1662957668304443, 0.0), + (0.243930846452713, 1.1662957668304443, 0.0), + (0.30480796098709106, 0.9197763204574585, 0.0), + (0.4225005805492401, 0.9877263307571411, 0.0), + (0.4225005805492401, 0.9877263307571411, 0.0), + (0.35196200013160706, 0.7437955141067505, 0.0), + (0.487861692905426, 0.74379563331604, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (-0.48786139488220215, 4.5077928945147505e-08, 0.0), + (0.48786139488220215, 4.5077928945147505e-08, 0.0), ) - def get_custom_shape(self, context: bpy.types.Context): + def get_custom_shape(self, context: bpy.types.Context) -> object: + """Get the appropriate custom shape based on lock state.""" obj = context.active_object if not obj: return self.custom_shape_closed @@ -492,7 +681,7 @@ class GizmoLock(bpy.types.Gizmo): try: is_open = obj.path_resolve(self.prop_path) return self.custom_shape_open if is_open else self.custom_shape_closed - except: + except (ValueError, KeyError, AttributeError): return self.custom_shape_closed def setup(self) -> None: @@ -507,7 +696,7 @@ class GizmoLock(bpy.types.Gizmo): class GizmoArc(bpy.types.Gizmo): - """Reusable quarter circle arc gizmo.""" + """Reusable arc gizmo for door swing visualization.""" bl_idname = "VIEW3D_GT_arc" @@ -518,14 +707,15 @@ class GizmoArc(bpy.types.Gizmo): ) def setup(self) -> None: - """Create quarter circle shapes for both LEFT and RIGHT directions.""" - arc_left = create_quarter_circle_arc(radius=1.0, direction="LEFT") - arc_right = create_quarter_circle_arc(radius=1.0, direction="RIGHT") + """Create arc shapes for both LEFT and RIGHT directions.""" + arc_left = create_circle_arc(radius=1.0, direction="LEFT", angle_min=2.0, angle_max=90.0) + arc_right = create_circle_arc(radius=1.0, direction="RIGHT", angle_min=2.0, angle_max=90.0) self.custom_shape_left = self.new_custom_shape(type="TRIS", verts=arc_left) self.custom_shape_right = self.new_custom_shape(type="TRIS", verts=arc_right) - def _get_shape_for_direction(self, context: bpy.types.Context): + def _get_shape_for_direction(self, context: bpy.types.Context) -> object: + """Get the appropriate arc shape based on door swing direction.""" obj = context.active_object if not obj: return self.custom_shape_left @@ -534,7 +724,7 @@ class GizmoArc(bpy.types.Gizmo): direction_value = obj.path_resolve(self.prop_path) if "RIGHT" in str(direction_value): return self.custom_shape_right - except: + except (ValueError, KeyError, AttributeError): pass return self.custom_shape_left @@ -663,6 +853,180 @@ class GizmoCancel(bpy.types.Gizmo): self.draw_custom_shape(self.custom_shape, select_id=select_id) +class GizmoPlus(bpy.types.Gizmo): + """Reusable plus/+ icon gizmo for incrementing values.""" + + bl_idname = "VIEW3D_GT_plus" + + __slots__ = ("custom_shape",) + + # Plus sign triangles (cross shape) - 50% larger + tris = ( + # Horizontal bar + (-0.375, -0.075, 0.0), + (-0.375, 0.075, 0.0), + (0.375, 0.075, 0.0), + (-0.375, -0.075, 0.0), + (0.375, 0.075, 0.0), + (0.375, -0.075, 0.0), + # Vertical bar + (-0.075, -0.375, 0.0), + (-0.075, 0.375, 0.0), + (0.075, 0.375, 0.0), + (-0.075, -0.375, 0.0), + (0.075, 0.375, 0.0), + (0.075, -0.375, 0.0), + ) + + def setup(self) -> None: + self.custom_shape = self.new_custom_shape("TRIS", self.tris) + + def draw(self, context: bpy.types.Context) -> None: + self.draw_custom_shape(self.custom_shape) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self.draw_custom_shape(self.custom_shape, select_id=select_id) + + +class GizmoMinus(bpy.types.Gizmo): + """Reusable minus/- icon gizmo for decrementing values.""" + + bl_idname = "VIEW3D_GT_minus" + + __slots__ = ("custom_shape",) + + # Minus sign triangles (horizontal bar) - 50% larger + tris = ( + (-0.375, -0.075, 0.0), + (-0.375, 0.075, 0.0), + (0.375, 0.075, 0.0), + (-0.375, -0.075, 0.0), + (0.375, 0.075, 0.0), + (0.375, -0.075, 0.0), + ) + + def setup(self) -> None: + self.custom_shape = self.new_custom_shape("TRIS", self.tris) + + def draw(self, context: bpy.types.Context) -> None: + self.draw_custom_shape(self.custom_shape) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self.draw_custom_shape(self.custom_shape, select_id=select_id) + + +def _generate_circular_arrow_tris() -> tuple[tuple[float, float, float], ...]: + """Generate circular arrow geometry (↻ style) covering ~300 degrees.""" + triangles = [] + radius = 0.375 # 50% larger than original 0.25 + line_width = 0.06 # 50% larger than original 0.04 + half_width = line_width / 2 + + # Arc from ~30 degrees to ~330 degrees (300 degree arc) + segments = 20 + start_angle = math.radians(30) + end_angle = math.radians(330) + angle_range = end_angle - start_angle + + # Generate arc points + arc_points = [] + for i in range(segments + 1): + angle = start_angle + angle_range * (i / segments) + x = radius * math.cos(angle) + y = radius * math.sin(angle) + arc_points.append((x, y)) + + # Create triangles for the arc (flat ribbon in XY plane with Z thickness) + for i in range(len(arc_points) - 1): + x1, y1 = arc_points[i] + x2, y2 = arc_points[i + 1] + + # Direction perpendicular to arc segment (for width in XY plane) + dx, dy = x2 - x1, y2 - y1 + length = (dx**2 + dy**2) ** 0.5 + if length > 0: + px, py = -dy / length * half_width, dx / length * half_width + + # XY plane triangles + triangles.extend([ + (x1 + px, y1 + py, 0.0), + (x1 - px, y1 - py, 0.0), + (x2 + px, y2 + py, 0.0), + ]) + triangles.extend([ + (x2 + px, y2 + py, 0.0), + (x1 - px, y1 - py, 0.0), + (x2 - px, y2 - py, 0.0), + ]) + + # XZ plane triangles (for visibility from other angles) + triangles.extend([ + (x1, y1, -half_width), + (x2, y2, -half_width), + (x1, y1, +half_width), + ]) + triangles.extend([ + (x1, y1, +half_width), + (x2, y2, -half_width), + (x2, y2, +half_width), + ]) + + # Arrowhead at the end of the arc (pointing in direction of cycling) + arrow_size = 0.18 # 50% larger than original 0.12 + end_x, end_y = arc_points[-1] + # Direction tangent to the arc at the end + prev_x, prev_y = arc_points[-2] + tangent_x = end_x - prev_x + tangent_y = end_y - prev_y + tangent_len = (tangent_x**2 + tangent_y**2) ** 0.5 + if tangent_len > 0: + tangent_x /= tangent_len + tangent_y /= tangent_len + + # Arrow tip extends in the tangent direction + tip_x = end_x + tangent_x * arrow_size * 0.5 + tip_y = end_y + tangent_y * arrow_size * 0.5 + + # Arrow base perpendicular to tangent + perp_x = -tangent_y * arrow_size + perp_y = tangent_x * arrow_size + + # Arrowhead triangle (XY plane) + triangles.extend([ + (tip_x, tip_y, 0.0), + (end_x - perp_x * 0.5, end_y - perp_y * 0.5, 0.0), + (end_x + perp_x * 0.5, end_y + perp_y * 0.5, 0.0), + ]) + + # Arrowhead triangle (XZ plane for depth) + triangles.extend([ + (tip_x, tip_y, 0.0), + (end_x, end_y, -arrow_size * 0.5), + (end_x, end_y, +arrow_size * 0.5), + ]) + + return tuple(triangles) + + +class GizmoCycle(bpy.types.Gizmo): + """Reusable circular arrow icon gizmo for cycling through enum values.""" + + bl_idname = "VIEW3D_GT_cycle" + + __slots__ = ("custom_shape",) + + tris = _generate_circular_arrow_tris() + + def setup(self) -> None: + self.custom_shape = self.new_custom_shape("TRIS", self.tris) + + def draw(self, context: bpy.types.Context) -> None: + self.draw_custom_shape(self.custom_shape) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self.draw_custom_shape(self.custom_shape, select_id=select_id) + + class GizmoArrow(GizmoMovable): """Arrow gizmo for directional value editing.""" @@ -803,3 +1167,214 @@ class GizmoCone(GizmoMovable): def draw_select(self, context: bpy.types.Context, select_id: int) -> None: self.draw_custom_shape(self.custom_shape, select_id=select_id) + + +class BaseParametricGizmoGroup: + """Base mixin class for parametric element gizmo groups (doors, windows, etc.). + + This class provides shared functionality for gizmo groups that edit + parametric BIM elements. Subclasses should define: + - gizmo_props: list of property configurations + - get_props(obj): method to get the element's properties + - get_gizmo_prefs(): method to get gizmo preferences + - element_type_check(element): method to check if element is the right type + - Operator bl_idnames for enable_editing, finish_editing, cancel_editing + + Example subclass: + class GizmoDoorEdition(bpy.types.GizmoGroup, BaseParametricGizmoGroup): + bl_idname = "OBJECT_GGT_bim_door_edition" + ... + """ + + COLOR_RED = (1.0, 0.2, 0.2) + COLOR_GREEN = (0.1, 0.8, 0.1) + COLOR_BLUE = (0.2, 0.2, 1.0) + ARROW_SCALE = 0.25 + + # Subclasses must define these + gizmo_props: list[GizmoPropConfig] = [] + enable_editing_operator: str = "" + finish_editing_operator: str = "" + cancel_editing_operator: str = "" + + @classmethod + def get_arrow_color_from_axis(cls, axis: tuple[int, int, int]) -> tuple[float, float, float]: + """Get arrow color based on axis direction (X=red, Y=green, Z=blue).""" + if axis[0] != 0: + return cls.COLOR_RED + elif axis[1] != 0: + return cls.COLOR_GREEN + return cls.COLOR_BLUE + + def get_axis_rotation_matrix(self, axis: tuple[int, int, int]) -> Matrix: + """Get rotation matrix to align arrow with the given axis.""" + axis_vec = Vector(axis).normalized() + default_dir = Vector((1, 0, 0)) + return default_dir.rotation_difference(axis_vec).to_matrix().to_4x4() + + @classmethod + def is_element_type(cls, element) -> bool: + """Check if the element is of the correct type. Must be overridden by subclass.""" + raise NotImplementedError("Subclass must implement is_element_type()") + + @classmethod + def poll(cls, context) -> bool: + """Show gizmo only when a single element of the correct type is selected and active.""" + prefs = tool.Blender.get_addon_preferences() + if not prefs.gizmos.draw_gizmos_in_3d_viewport: + return False + + obj = tool.Blender.get_active_object(is_selected=True) + if not obj: + return False + + if len(tool.Blender.get_selected_objects()) != 1: + return False + + element = tool.Ifc.get_entity(obj) + if not element or not cls.is_element_type(element): + return False + return True + + def get_props(self, obj: bpy.types.Object) -> Any: + """Get properties for the element. Must be overridden by subclass.""" + raise NotImplementedError("Subclass must implement get_props()") + + def get_gizmo_prefs(self) -> Any: + """Get gizmo preferences for this element type. Must be overridden by subclass.""" + raise NotImplementedError("Subclass must implement get_gizmo_prefs()") + + def get_prop_min_value(self, attr_name: str) -> float: + """Get minimum value for a property. Override to customize.""" + return 0.0 + + def should_hide_gizmo(self, attr_name: str, props) -> bool: + """Check if a specific gizmo should be hidden. Override to add visibility rules.""" + return not props.is_editing + + def get_element_height(self, props) -> float: + """Get the element height for icon positioning. Override if property name differs.""" + return getattr(props, "overall_height", getattr(props, "height", 1.0)) + + def setup_property_gizmos(self, context: bpy.types.Context) -> None: + """Set up gizmos for all properties in gizmo_props.""" + prefs = tool.Blender.get_addon_preferences() + highlight_color = prefs.decorator_color_selected[:3] + + for prop_config in self.gizmo_props: + attr_name = prop_config.attr_name + invert_delta = prop_config.invert_delta + gizmo = self.gizmos.new("BIM_GT_gizmo_cone") + + # Create closures that capture attr_name + def make_move_get(name): + def move_get(): + obj = bpy.context.active_object + if not obj: + return 0.0 + props = self.get_props(obj) + return getattr(props, name) + + return move_get + + def make_move_set(name): + def move_set(value): + obj = bpy.context.active_object + if not obj: + return + props = self.get_props(obj) + min_val = self.get_prop_min_value(name) + setattr(props, name, max(min_val, value)) + + return move_set + + gizmo.move_get_cb = make_move_get(attr_name) + gizmo.move_set_cb = make_move_set(attr_name) + gizmo.axis = Vector(prop_config.axis) + gizmo.local_axis = Vector(prop_config.axis) + gizmo.invert_delta = invert_delta + + gizmo.color = self.get_arrow_color_from_axis(prop_config.axis) + gizmo.color_highlight = highlight_color + gizmo.alpha = 0.99 + gizmo.use_draw_modal = True + gizmo.use_draw_scale = True + + setattr(self, f"gizmo_{attr_name}", gizmo) + + def setup_editing_gizmos(self, context: bpy.types.Context) -> None: + """Set up pen, validate, and cancel gizmos for editing mode.""" + prefs = tool.Blender.get_addon_preferences() + default_color = prefs.decorations_colour[:3] + highlight_color = prefs.decorator_color_selected[:3] + + 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) + + def update_property_gizmos(self, mw, props) -> None: + """Update arrow gizmos position and visibility based on editing state.""" + gizmo_prefs = self.get_gizmo_prefs() + + for prop_config in self.gizmo_props: + attr_name = prop_config.attr_name + gizmo = getattr(self, f"gizmo_{attr_name}", None) + if gizmo is None: + continue + + # Check preferences + if not getattr(gizmo_prefs, attr_name, True): + gizmo.hide = True + continue + + # Check visibility rules + if self.should_hide_gizmo(attr_name, props): + gizmo.hide = True + continue + + # Update position and show arrow + gizmo.hide = False + matrix_method = getattr(self, f"get_gizmo_matrix_{attr_name}", None) + if matrix_method: + gizmo.matrix_basis = mw @ matrix_method(props) + gizmo.matrix_offset = Matrix.Scale(self.ARROW_SCALE, 4) + + def update_editing_gizmos(self, mw, props) -> None: + """Update editing control gizmos (pen/validate/cancel) visibility and position.""" + icon_z = self.get_element_height(props) + 0.5 + local_transform = ( + Matrix.Translation(Vector((0, 0.0, icon_z))) + @ Matrix.Rotation(math.radians(90), 4, (1.0, 0.0, 0.0)) + @ Matrix.Scale(0.5, 4) + ) + icon_matrix_base = mw @ local_transform + + if props.is_editing: + self.pen_gizmo.hide = True + self.validate_gizmo.hide = False + self.validate_gizmo.matrix_basis = icon_matrix_base + self.cancel_gizmo.hide = False + cancel_local = Matrix.Translation(Vector((0.5, 0.0, 0.0))) @ local_transform + self.cancel_gizmo.matrix_basis = mw @ cancel_local + else: + self.pen_gizmo.hide = False + self.pen_gizmo.matrix_basis = icon_matrix_base + self.validate_gizmo.hide = True + self.cancel_gizmo.hide = True diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index dd4bfc0c3b..f33d851887 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -161,6 +161,10 @@ classes = ( stair.FinishEditingStair, stair.EnableEditingStair, stair.RemoveStair, + stair.ToggleStairTotalLengthLock, + stair.AdjustStairTreads, + stair.CycleStairType, + stair.GizmoStairEdition, sverchok_modifier.CreateNewSverchokGraph, sverchok_modifier.UpdateDataFromSverchok, sverchok_modifier.DeleteSverchokGraph, @@ -172,6 +176,7 @@ classes = ( window.FinishEditingWindow, window.EnableEditingWindow, window.RemoveWindow, + window.GizmoWindowEdition, door.BIM_OT_add_door, door.AddDoor, door.CancelEditingDoor, diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index 7a12b724cf..d470e921bd 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -33,10 +33,10 @@ 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 +from bonsai.bim import gizmo +from bonsai.bim.gizmo import GizmoPropConfig -import math from mathutils import Vector, Matrix -from typing import Union, Any, Optional import json import collections @@ -193,8 +193,8 @@ def bm_mirror( def create_bm_extruded_profile( bm: bmesh.types.BMesh, points: list[Vector], - edges: Optional[list[tuple[int, int]]] = None, - faces: Optional[list[list[int]]] = None, + edges: list[tuple[int, int]] | None = None, + faces: list[list[int]] | None = None, position: Vector = V_(0, 0, 0).freeze(), magnitude: float = 1.0, extrusion_vector: Vector = V_(0, 0, 1).freeze(), @@ -473,6 +473,7 @@ def update_door_modifier_bmesh(context: bpy.types.Context) -> None: lining_offset_verts = lining_verts + door_verts + window_lining_verts + frame_verts + glass_verts bmesh.ops.translate(bm, vec=V_(0, lining_offset, 0), verts=lining_offset_verts) bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) if bpy.context.active_object.mode == "EDIT": bmesh.update_edit_mesh(obj.data) @@ -684,104 +685,118 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator): class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator): - """Toggle door swing direction between LEFT and RIGHT""" + """Toggle door swing direction and optionally flip door geometry. + + Shift+Click (when flip_geometry=True): Flip geometry only without changing door direction""" bl_idname = "bim.toggle_door_swing" bl_label = "Toggle Door Swing" bl_options = {"REGISTER", "UNDO"} + flip_geometry: bpy.props.BoolProperty(name="Flip Geometry", default=False) + flip_local_axes: bpy.props.EnumProperty( + name="Flip Local Axes", items=(("XY", "XY", ""), ("YZ", "YZ", ""), ("XZ", "XZ", "")), default="XY" + ) + skip_direction_change: bpy.props.BoolProperty( + name="Skip Direction Change", default=False, options={"HIDDEN", "SKIP_SAVE"} + ) + + def invoke(self, context, event): + self.skip_direction_change = event.shift + return self.execute(context) + + def _toggle_swing_direction(self, obj: bpy.types.Object) -> bool: + """Toggle door swing direction between LEFT and RIGHT.""" + props = tool.Model.get_door_props(obj) + current_type = props.door_type + + if "LEFT" in current_type: + props.door_type = current_type.replace("LEFT", "RIGHT") + return True + elif "RIGHT" in current_type: + props.door_type = current_type.replace("RIGHT", "LEFT") + return True + return False + 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): + if not element: return {"CANCELLED"} - props = tool.Model.get_door_props(obj) - current_type = props.door_type + is_door = tool.Blender.Modifier.is_door(element) - if "LEFT" in current_type: - props.door_type = current_type.replace("LEFT", "RIGHT") - elif "RIGHT" in current_type: - props.door_type = current_type.replace("RIGHT", "LEFT") + if self.flip_geometry: + tool.Geometry.flip_object(obj, self.flip_local_axes) + if not self.skip_direction_change and is_door: + self._toggle_swing_direction(obj) + elif is_door: + self._toggle_swing_direction(obj) + else: + return {"CANCELLED"} return {"FINISHED"} -class GizmoDoorEdition(bpy.types.GizmoGroup): +class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): bl_idname = "OBJECT_GGT_bim_door_edition" bl_label = "Door Editing Gizmo" bl_space_type = "VIEW_3D" bl_region_type = "WINDOW" bl_options = {"3D", "PERSISTENT"} - COLOR_RED = (1.0, 0.2, 0.2) - COLOR_GREEN = (0.1, 0.8, 0.1) - COLOR_BLUE = (0.2, 0.2, 1.0) - ARROW_SCALE = 0.25 - - @classmethod - def get_arrow_color_from_axis(cls, axis): - """Get arrow color based on axis direction (X=red, Y=green, Z=blue).""" - if axis[0] != 0: - return cls.COLOR_RED - elif axis[1] != 0: - return cls.COLOR_GREEN - else: - return cls.COLOR_BLUE + enable_editing_operator = "bim.enable_editing_door" + finish_editing_operator = "bim.finish_editing_door" + cancel_editing_operator = "bim.cancel_editing_door" gizmo_props = [ - {"attr_name": "overall_height", "axis": (0, 0, 1)}, - {"attr_name": "overall_width", "axis": (1, 0, 0)}, - {"attr_name": "threshold_thickness", "axis": (0, 0, 1)}, - {"attr_name": "threshold_depth", "axis": (0, 1, 0)}, - {"attr_name": "lining_depth", "axis": (0, 1, 0)}, - {"attr_name": "lining_thickness", "axis": (1, 0, 0)}, - {"attr_name": "transom_offset", "axis": (0, 0, 1)}, - {"attr_name": "transom_thickness", "axis": (0, 0, 1)}, + GizmoPropConfig("overall_height", (0, 0, 1)), + GizmoPropConfig("overall_width", (1, 0, 0)), + GizmoPropConfig("threshold_thickness", (0, 0, 1)), + GizmoPropConfig("threshold_depth", (0, 1, 0)), + GizmoPropConfig("lining_depth", (0, 1, 0)), + GizmoPropConfig("lining_thickness", (-1, 0, 0)), + GizmoPropConfig("transom_offset", (0, 0, 1)), + GizmoPropConfig("transom_thickness", (0, 0, 1)), + GizmoPropConfig("casing_thickness", (-1, 0, 0)), + GizmoPropConfig("casing_depth", (0, 1, 0)), ] @classmethod - def poll(cls, context): - """Show gizmo only when a single door object is selected and active.""" + def is_element_type(cls, element) -> bool: + return tool.Blender.Modifier.is_door(element) + + def get_props(self, obj): + return tool.Model.get_door_props(obj) + + def get_gizmo_prefs(self): prefs = tool.Blender.get_addon_preferences() - if not prefs.gizmos.draw_gizmos_in_3d_viewport: - return False + return prefs.gizmos.door - obj = tool.Blender.get_active_object(is_selected=True) - if not obj: - return False - - if len(tool.Blender.get_selected_objects()) != 1: - return False - - element = tool.Ifc.get_entity(obj) - if not element or not tool.Blender.Modifier.is_door(element): - return False - return True - - def get_axis_rotation_matrix(self, axis): - """Get rotation matrix to align arrow (default +X direction) with the given axis. - - Args: - axis: Tuple of (x, y, z) representing the target axis direction - - Returns: - Rotation matrix to align arrow with the axis - """ - axis_vec = V_(*axis).normalized() - default_dir = V_(1, 0, 0) - return default_dir.rotation_difference(axis_vec).to_matrix().to_4x4() + def should_hide_gizmo(self, attr_name, props): + """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_gizmo_matrix_overall_height(self, props): - translation = Matrix.Translation(V_(props.overall_width - 0.05, 0.0, props.overall_height)) + translation = Matrix.Translation(V_(props.overall_width - 0.05, props.lining_offset, props.overall_height)) rotation = self.get_axis_rotation_matrix((0, 0, 1)) return translation @ rotation def get_gizmo_matrix_overall_width(self, props): - translation = Matrix.Translation(V_(props.overall_width, 0.0, props.overall_height - 0.05)) + translation = Matrix.Translation(V_(props.overall_width, props.lining_offset, props.overall_height - 0.05)) rotation = self.get_axis_rotation_matrix((1, 0, 0)) return translation @ rotation @@ -789,7 +804,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup): translation = Matrix.Translation( V_( props.overall_width / 2, - props.threshold_offset + props.threshold_depth / 2, + props.threshold_offset + props.threshold_depth / 2 + props.lining_offset, props.threshold_thickness, ) ) @@ -798,7 +813,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup): def get_gizmo_matrix_threshold_depth(self, props): translation = Matrix.Translation( - V_(props.overall_width / 2, props.threshold_depth, props.threshold_thickness / 2) + V_(props.overall_width / 2, props.threshold_depth + props.lining_offset, props.threshold_thickness / 2) ) rotation = self.get_axis_rotation_matrix((0, 1, 0)) return translation @ rotation @@ -812,146 +827,86 @@ class GizmoDoorEdition(bpy.types.GizmoGroup): def get_gizmo_matrix_lining_thickness(self, props): translation = Matrix.Translation( - V_(props.lining_thickness, props.lining_depth / 2 + props.lining_offset, props.overall_height / 2) + V_(props.overall_width - props.lining_thickness, props.lining_depth / 2, props.overall_height / 2) ) - rotation = self.get_axis_rotation_matrix((1, 0, 0)) + rotation = self.get_axis_rotation_matrix((-1, 0, 0)) return translation @ rotation def get_gizmo_matrix_transom_offset(self, props): - translation = Matrix.Translation(V_(props.overall_width / 2, 0.0, props.transom_offset)) + translation = Matrix.Translation(V_(props.overall_width / 2, props.lining_offset, props.transom_offset)) rotation = self.get_axis_rotation_matrix((0, 0, 1)) return translation @ rotation def get_gizmo_matrix_transom_thickness(self, props): translation = Matrix.Translation( - V_(props.overall_width / 2, 0.0, props.transom_offset + props.transom_thickness) + V_(props.overall_width / 2, props.lining_offset, props.transom_offset + props.transom_thickness) ) rotation = self.get_axis_rotation_matrix((0, 0, 1)) return translation @ rotation + @staticmethod + def _get_casing_gizmo_base_position(props) -> tuple[float, float, float]: + """Get common base position for casing gizmos.""" + x = -props.casing_thickness + props.lining_thickness + y_base = props.lining_depth + props.lining_offset + z = props.overall_height / 2 + return x, y_base, z + + def get_gizmo_matrix_casing_thickness(self, props): + x, y_base, z = self._get_casing_gizmo_base_position(props) + translation = Matrix.Translation(V_(x, y_base + props.casing_depth / 2, z)) + rotation = self.get_axis_rotation_matrix((-1, 0, 0)) + return translation @ rotation + + def get_gizmo_matrix_casing_depth(self, props): + x, y_base, z = self._get_casing_gizmo_base_position(props) + translation = Matrix.Translation(V_(x, y_base + props.casing_depth, z)) + rotation = self.get_axis_rotation_matrix((0, 1, 0)) + return translation @ rotation + def setup(self, context): + # Use base class methods for common gizmos + self.setup_property_gizmos(context) + self.setup_editing_gizmos(context) + + # Door-specific: swing arc gizmos prefs = tool.Blender.get_addon_preferences() - default_color = prefs.decorations_colour[:3] highlight_color = prefs.decorator_color_selected[:3] inactive_color = prefs.decorator_color_background[:3] special_color = prefs.decorator_color_special[:3] - def add_gizmo_prop(prop_config): - attr_name = prop_config["attr_name"] - gizmo = self.gizmos.new("BIM_GT_gizmo_cone") - - def move_get(): - obj = bpy.context.active_object - if not obj: - return 0.0 - props = tool.Model.get_door_props(obj) - return getattr(props, attr_name) - - def move_set(value): - obj = bpy.context.active_object - if not obj: - return - props = tool.Model.get_door_props(obj) - setattr(props, attr_name, max(0.0, value)) - - # Set callbacks on the gizmo instance - gizmo.move_get_cb = move_get - gizmo.move_set_cb = move_set - gizmo.axis = V_(*prop_config["axis"]) - gizmo.local_axis = V_(*prop_config["axis"]) - - gizmo.color = self.get_arrow_color_from_axis(prop_config["axis"]) - gizmo.color_highlight = highlight_color - gizmo.alpha = 0.99 - gizmo.use_draw_modal = True - gizmo.use_draw_scale = True - - setattr(self, f"gizmo_{attr_name}", gizmo) - - for prop_config in self.gizmo_props: - add_gizmo_prop(prop_config) - 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" - self.gizmo_door_type.target_set_operator("bim.toggle_door_swing") + op = self.gizmo_door_type.target_set_operator("bim.toggle_door_swing") + op.flip_geometry = False - # Flip arc gizmo - mirrors the swing arc along X axis, flips door when clicked + # Flip arc gizmo - mirrors the swing arc along X axis, flips door and toggles direction when clicked 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.flip_object") + op = self.gizmo_flip_arc.target_set_operator("bim.toggle_door_swing") + op.flip_geometry = True op.flip_local_axes = "XY" - 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("bim.enable_editing_door") - - 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("bim.finish_editing_door") - - 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("bim.cancel_editing_door") - def refresh(self, context): obj = context.active_object if not obj: return - props = tool.Model.get_door_props(obj) + props = self.get_props(obj) mw = obj.matrix_world - self.update_arrows(mw, props) - self.update_swing_gizmo(mw, props) + self.update_property_gizmos(mw, props) + self.update_swing_gizmos(mw, props) self.update_editing_gizmos(mw, props) - def update_arrows(self, mw, props): - """Update arrow gizmos position and visibility based on editing state.""" - prefs = tool.Blender.get_addon_preferences() - door_gizmo_prefs = prefs.gizmos.door - - for prop_config in self.gizmo_props: - attr_name = prop_config["attr_name"] - gizmo = getattr(self, f"gizmo_{attr_name}", None) - if gizmo is None: - continue - - if not getattr(door_gizmo_prefs, attr_name, True): - gizmo.hide = True - continue - - if ( - not props.is_editing - or (attr_name == "threshold_depth" and props.threshold_thickness == 0.0) - or (attr_name == "transom_offset" and props.transom_thickness == 0.0) - ): - gizmo.hide = True - continue - - # Update position and show arrow when editing - gizmo.hide = False - matrix_method = getattr(self, f"get_gizmo_matrix_{attr_name}", None) - if matrix_method: - gizmo.matrix_basis = mw @ matrix_method(props) - gizmo.matrix_offset = Matrix.Scale(self.ARROW_SCALE, 4) - - def update_swing_gizmo(self, mw, props): + def update_swing_gizmos(self, mw, props): """Update swing gizmo position and color based on editing state.""" prefs = tool.Blender.get_addon_preferences() door_gizmo_prefs = prefs.gizmos.door @@ -965,37 +920,13 @@ class GizmoDoorEdition(bpy.types.GizmoGroup): # Calculate base swing transformation (common to both arcs) swing_x_offset = props.overall_width if "RIGHT" in props.door_type else 0.0 - base_swing_transform = Matrix.Translation(V_(swing_x_offset, 0, 0)) @ Matrix.Scale(props.overall_width, 4) + base_swing_transform = Matrix.Translation(V_(swing_x_offset, props.lining_offset, 0)) @ Matrix.Scale(props.overall_width, 4) if not self.gizmo_door_type.hide: 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: - mirror_x = Matrix.Scale(-1, 4, (0, 1, 0)) - self.gizmo_flip_arc.matrix_basis = mw @ base_swing_transform @ mirror_x - - def update_editing_gizmos(self, mw, props): - """Update editing control gizmos (pen/validate/cancel) visibility and position.""" - - icon_z = props.overall_height + 0.5 - local_transform = ( - Matrix.Translation(V_(0, 0.0, icon_z)) - @ Matrix.Rotation(math.radians(90), 4, (1.0, 0.0, 0.0)) - @ Matrix.Scale(0.5, 4) - ) - icon_matrix_base = mw @ local_transform - - if props.is_editing: - self.pen_gizmo.hide = True - self.validate_gizmo.hide = False - self.validate_gizmo.matrix_basis = icon_matrix_base - self.cancel_gizmo.hide = False - cancel_local = Matrix.Translation(V_(0.5, 0.0, 0.0)) @ local_transform - self.cancel_gizmo.matrix_basis = mw @ cancel_local - - else: - self.pen_gizmo.hide = False - self.pen_gizmo.matrix_basis = icon_matrix_base - self.validate_gizmo.hide = True - self.cancel_gizmo.hide = True + # Mirror the flip arc along Y axis to show the opposite swing direction + 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 303ce43d41..9f61e751c2 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -214,10 +214,17 @@ class BIMModelProperties(PropertyGroup): # Used for things like windows, other hosted furniture, and MEP rl2: bpy.props.FloatProperty(name="RL", default=1, subtype="DISTANCE", description="Z offset for windows") # Used for plan calculation points such as in room generation - rl3: bpy.props.FloatProperty(name="RL", default=1, subtype="DISTANCE", description="Z offset for space calculation") + rl3: bpy.props.FloatProperty( + name="RL", default=1, subtype="DISTANCE", description="Z offset for space calculation" + ) type_page: bpy.props.IntProperty(name="Type Page", default=1, min=1, update=update_type_page) x_angle: bpy.props.FloatProperty( - name="X Angle", default=0, subtype="ANGLE", min=math.radians(-180), max=math.radians(180), update=update_x_angle + name="X Angle", + default=0, + subtype="ANGLE", + min=math.radians(-180), + max=math.radians(180), + update=update_x_angle, ) type_name: bpy.props.StringProperty(name="Name", default="TYPEX") boundary_class: bpy.props.EnumProperty(items=get_boundary_class, name="Boundary Class") @@ -238,7 +245,9 @@ class BIMModelProperties(PropertyGroup): default="TOP", description="Offset convention to reference line", ) - offset: bpy.props.FloatProperty(name="Offset", default=0.0, description="Material usage offset from reference line") + offset: bpy.props.FloatProperty( + name="Offset", default=0.0, description="Material usage offset from reference line" + ) show_wall_axis: bpy.props.BoolProperty( name="Show Wall Axis", default=False, @@ -717,9 +726,11 @@ class BIMWindowProperties(PropertyGroup): overall_width: bpy.props.FloatProperty(name="Overall Width", default=0.6, subtype="DISTANCE", update=update_window) # lining properties - lining_depth: bpy.props.FloatProperty(name="Lining Depth", default=0.050, subtype="DISTANCE", update=update_window) + lining_depth: bpy.props.FloatProperty( + name="Lining Depth", default=0.050, min=0.001, subtype="DISTANCE", update=update_window + ) lining_thickness: bpy.props.FloatProperty( - name="Lining Thickness", default=0.050, subtype="DISTANCE", update=update_window + name="Lining Thickness", default=0.050, min=0.001, subtype="DISTANCE", update=update_window ) lining_offset: bpy.props.FloatProperty( name="Lining Offset", default=0.050, subtype="DISTANCE", update=update_window @@ -748,12 +759,13 @@ class BIMWindowProperties(PropertyGroup): update=update_window, ) transom_thickness: bpy.props.FloatProperty( - name="Transom Thickness", default=0.050, subtype="DISTANCE", update=update_window + name="Transom Thickness", default=0.050, min=0.001, subtype="DISTANCE", update=update_window ) first_transom_offset: bpy.props.FloatProperty( name="First Transom Offset", description="Distance from the first lining to the first transom center", default=0.3, + min=0.001, subtype="DISTANCE", update=update_window, ) diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index 38480089e3..a6bd0ad8af 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -19,6 +19,7 @@ import bpy import json import bmesh +import math import ifcopenshell import ifcopenshell.api.pset import ifcopenshell.util.element @@ -26,7 +27,9 @@ import ifcopenshell.util.representation import ifcopenshell.util.unit import bonsai.core.root import bonsai.tool as tool -from mathutils import Vector +from bonsai.bim import gizmo +from bonsai.bim.gizmo import GizmoPropConfig +from mathutils import Vector, Matrix from bmesh.types import BMVert from bpy.types import Operator from bpy.props import FloatProperty, IntProperty @@ -293,3 +296,309 @@ class RemoveStair(bpy.types.Operator, tool.Ifc.Operator): ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset) return {"FINISHED"} + + +class ToggleStairTotalLengthLock(bpy.types.Operator): + """Toggle the total length lock for stair editing""" + + bl_idname = "bim.toggle_stair_total_length_lock" + bl_label = "Toggle Stair Total Length 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.total_length_lock = not props.total_length_lock + + return {"FINISHED"} + + +class AdjustStairTreads(bpy.types.Operator): + """Adjust the number of treads""" + + bl_idname = "bim.adjust_stair_treads" + bl_label = "Adjust Stair Treads" + bl_options = {"REGISTER", "UNDO"} + + increment: IntProperty(name="Increment", default=1) + + def execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + + props = tool.Model.get_stair_props(obj) + new_value = props.number_of_treads + self.increment + if new_value >= 1: + props.number_of_treads = new_value + + return {"FINISHED"} + + +class CycleStairType(bpy.types.Operator): + """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"}) + + 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) + types = ["CONCRETE", "WOOD/STEEL", "GENERIC"] + try: + current_idx = types.index(props.stair_type) + except ValueError: + current_idx = 0 + direction = -1 if self.reverse else 1 + props.stair_type = types[(current_idx + direction) % len(types)] + + return {"FINISHED"} + + +class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): + bl_idname = "OBJECT_GGT_bim_stair_edition" + bl_label = "Stair Editing Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + enable_editing_operator = "bim.enable_editing_stair" + finish_editing_operator = "bim.finish_editing_stair" + cancel_editing_operator = "bim.cancel_editing_stair" + + gizmo_props = [ + GizmoPropConfig("width", (0, 1, 0)), + GizmoPropConfig("height", (0, 0, 1)), + GizmoPropConfig("tread_run", (1, 0, 0)), + GizmoPropConfig("tread_depth", (0, 0, -1)), + GizmoPropConfig("nosing_length", (-1, 0, 0)), + GizmoPropConfig("nosing_depth", (0, 0, -1)), + GizmoPropConfig("total_length_target", (1, 0, 0)), + GizmoPropConfig("base_slab_depth", (0, 0, -1)), + GizmoPropConfig("top_slab_depth", (0, 0, -1)), + ] + + @classmethod + def is_element_type(cls, element) -> bool: + return tool.Blender.Modifier.is_stair(element) + + def get_props(self, obj): + return tool.Model.get_stair_props(obj) + + def get_gizmo_prefs(self): + prefs = tool.Blender.get_addon_preferences() + return prefs.gizmos.stair + + def should_hide_gizmo(self, attr_name, props): + """Stair-specific visibility rules for gizmos.""" + if not props.is_editing: + return True + # Hide concrete-specific gizmos for non-concrete stairs + if attr_name in ("base_slab_depth", "top_slab_depth") and props.stair_type != "CONCRETE": + return True + # Hide tread_depth for generic stairs (has no tread geometry) + if attr_name == "tread_depth" and props.stair_type == "GENERIC": + return True + # Hide nosing_depth when nosing_length is 0 or for Wood/Steel stair types + if attr_name == "nosing_depth" and (props.nosing_length == 0.0 or props.stair_type == "WOOD/STEEL"): + return True + return False + + @staticmethod + def _get_stair_total_run(props) -> float: + """Calculate the total horizontal run of the stair.""" + return props.tread_run * props.number_of_treads + + @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_gizmo_matrix_width(self, props): + translation = Matrix.Translation(Vector((0, props.width, 0))) + rotation = self.get_axis_rotation_matrix((0, 1, 0)) + return translation @ rotation + + def get_gizmo_matrix_height(self, props): + total_run = self._get_stair_total_run(props) + translation = Matrix.Translation(Vector((total_run, props.width / 2, props.height))) + rotation = self.get_axis_rotation_matrix((0, 0, 1)) + return translation @ rotation + + def get_gizmo_matrix_tread_run(self, props): + riser_height = self._get_first_riser_height(props) + translation = Matrix.Translation(Vector((props.tread_run, props.width / 2, riser_height))) + rotation = self.get_axis_rotation_matrix((1, 0, 0)) + return translation @ rotation + + def get_gizmo_matrix_tread_depth(self, props): + riser_height = self._get_first_riser_height(props) + translation = Matrix.Translation(Vector((props.tread_run / 2, props.width / 2, riser_height - props.tread_depth))) + rotation = self.get_axis_rotation_matrix((0, 0, -1)) + return translation @ rotation + + def get_gizmo_matrix_nosing_length(self, props): + riser_height = self._get_first_riser_height(props) + translation = Matrix.Translation(Vector((-props.nosing_length, props.width / 2, riser_height))) + rotation = self.get_axis_rotation_matrix((-1, 0, 0)) + return translation @ rotation + + def get_gizmo_matrix_nosing_depth(self, props): + riser_height = self._get_first_riser_height(props) + translation = Matrix.Translation(Vector((-props.nosing_length, props.width / 2, riser_height - props.nosing_depth))) + rotation = self.get_axis_rotation_matrix((0, 0, -1)) + return translation @ rotation + + def get_gizmo_matrix_total_length_target(self, props): + translation = Matrix.Translation(Vector((props.total_length_target, props.width / 2, props.height))) + rotation = self.get_axis_rotation_matrix((1, 0, 0)) + return translation @ rotation + + def get_gizmo_matrix_base_slab_depth(self, props): + translation = Matrix.Translation(Vector((0, props.width / 2, -props.base_slab_depth))) + rotation = self.get_axis_rotation_matrix((0, 0, -1)) + return translation @ rotation + + def get_gizmo_matrix_top_slab_depth(self, props): + total_run = self._get_stair_total_run(props) + translation = Matrix.Translation(Vector((total_run, props.width / 2, props.height - props.top_slab_depth))) + rotation = self.get_axis_rotation_matrix((0, 0, -1)) + return translation @ rotation + + def setup(self, context): + self.setup_property_gizmos(context) + self.setup_editing_gizmos(context) + + # Stair-specific gizmos + prefs = tool.Blender.get_addon_preferences() + highlight_color = prefs.decorator_color_selected[:3] + + # Total length lock gizmo + 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") + + # Plus gizmo for increasing treads + 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 + + # Minus gizmo for decreasing treads + 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 + + # Cycle gizmo for stair type + default_color = prefs.decorations_colour[:3] + 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("bim.cycle_stair_type") + + def refresh(self, context): + obj = context.active_object + if not obj: + return + + props = self.get_props(obj) + mw = obj.matrix_world + self.update_property_gizmos(mw, props) + self.update_editing_gizmos(mw, props) + self.update_lock_gizmo(mw, props) + self.update_tread_count_gizmos(mw, props) + self.update_cycle_gizmo(mw, props) + + def update_lock_gizmo(self, mw, props): + """Update lock gizmo position and visibility.""" + 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 + + # Update color based on lock state: red when locked, green when unlocked + self.lock_gizmo.color = self.COLOR_RED if props.total_length_lock else self.COLOR_GREEN + + total_run = self._get_stair_total_run(props) + lock_x = total_run + props.tread_run + 0.5 + local_transform = ( + Matrix.Translation(Vector((lock_x, props.width / 2, props.height))) + @ Matrix.Rotation(math.radians(90), 4, (1.0, 0.0, 0.0)) + @ Matrix.Scale(0.2, 4) + ) + self.lock_gizmo.matrix_basis = mw @ local_transform + + def update_tread_count_gizmos(self, mw, props): + """Update plus and minus gizmos for tread count adjustment.""" + gizmo_prefs = self.get_gizmo_prefs() + + # Plus gizmo - always visible when editing (if preference enabled) + self.plus_gizmo.hide = not props.is_editing or not gizmo_prefs.plus + # Minus gizmo - hidden when number_of_treads is 1 (if preference enabled) + self.minus_gizmo.hide = not props.is_editing or props.number_of_treads <= 1 or not gizmo_prefs.minus + + if self.plus_gizmo.hide and self.minus_gizmo.hide: + return + + total_run = self._get_stair_total_run(props) + base_x = total_run + props.tread_run + 0.5 + + if not self.plus_gizmo.hide: + plus_local_transform = ( + Matrix.Translation(Vector((base_x + 0.25, props.width / 2, props.height + 0.15))) + @ Matrix.Rotation(math.radians(90), 4, (1.0, 0.0, 0.0)) + @ Matrix.Scale(0.2, 4) + ) + self.plus_gizmo.matrix_basis = mw @ plus_local_transform + + if not self.minus_gizmo.hide: + minus_local_transform = ( + Matrix.Translation(Vector((base_x + 0.5, props.width / 2, props.height + 0.15))) + @ Matrix.Rotation(math.radians(90), 4, (1.0, 0.0, 0.0)) + @ Matrix.Scale(0.2, 4) + ) + self.minus_gizmo.matrix_basis = mw @ minus_local_transform + + def update_cycle_gizmo(self, mw, props): + """Update cycle gizmo position - 0.5m above lock gizmo.""" + gizmo_prefs = self.get_gizmo_prefs() + self.cycle_gizmo.hide = not props.is_editing or not gizmo_prefs.cycle + + if self.cycle_gizmo.hide: + return + + total_run = self._get_stair_total_run(props) + cycle_x = total_run + props.tread_run + 0.5 # Same X as lock + local_transform = ( + Matrix.Translation(Vector((cycle_x, props.width / 2, props.height + 0.5))) # +0.5m above lock + @ Matrix.Rotation(math.radians(90), 4, (1.0, 0.0, 0.0)) + @ Matrix.Scale(0.2, 4) + ) + self.cycle_gizmo.matrix_basis = mw @ local_transform diff --git a/src/bonsai/bonsai/bim/module/model/window.py b/src/bonsai/bonsai/bim/module/model/window.py index c166ff7d54..9f3f68d4b3 100644 --- a/src/bonsai/bonsai/bim/module/model/window.py +++ b/src/bonsai/bonsai/bim/module/model/window.py @@ -28,6 +28,8 @@ import ifcopenshell.api.pset import bonsai.tool as tool import bonsai.core.root import bonsai.core.geometry +from bonsai.bim import gizmo +from bonsai.bim.gizmo import GizmoPropConfig from ifcopenshell.api.geometry.add_window_representation import DEFAULT_PANEL_SCHEMAS import ifcopenshell.api import ifcopenshell.api.material @@ -36,8 +38,7 @@ import ifcopenshell.util.representation import ifcopenshell.util.shape_builder import ifcopenshell.util.unit from bmesh.types import BMVert -from mathutils import Vector -from typing import Optional, Union +from mathutils import Vector, Matrix V_ = tool.Blender.V_ @@ -139,7 +140,7 @@ def update_window_modifier_representation(context: bpy.types.Context) -> None: def create_bm_window_frame( - bm: bmesh.types.BMesh, size: Vector, thickness: Union[float, list[float]], position: Vector = V_(0, 0, 0).freeze() + bm: bmesh.types.BMesh, size: Vector, thickness: float | list[float], position: Vector = V_(0, 0, 0).freeze() ) -> list[bmesh.types.BMVert]: """`thickness` of the profile is defined as list in the following order: `(LEFT, TOP, RIGHT, BOTTOM)` @@ -226,7 +227,7 @@ def create_bm_window( frame_thickness: float, glass_thickness: float, position: Vector, - x_offsets: Optional[list] = None, + x_offsets: list | None = None, ) -> tuple[list[bmesh.types.BMVert], list[bmesh.types.BMVert], list[bmesh.types.BMVert]]: """`lining_thickness` and `x_offsets` are expected to be defined as a list, similarly to `create_bm_window_frame` `thickness` argument""" @@ -391,6 +392,7 @@ def update_window_modifier_bmesh(context: bpy.types.Context) -> None: bmesh.ops.translate(bm, vec=V_(0, lining_offset, 0), verts=bm.verts) bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) if bpy.context.active_object.mode == "EDIT": bmesh.update_edit_mesh(obj.data) @@ -575,3 +577,199 @@ class RemoveWindow(bpy.types.Operator, tool.Ifc.Operator): ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset) return {"FINISHED"} + + +class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): + bl_idname = "OBJECT_GGT_bim_window_edition" + bl_label = "Window Editing Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + enable_editing_operator = "bim.enable_editing_window" + finish_editing_operator = "bim.finish_editing_window" + cancel_editing_operator = "bim.cancel_editing_window" + + gizmo_props = [ + GizmoPropConfig("overall_height", (0, 0, 1)), + GizmoPropConfig("overall_width", (1, 0, 0)), + GizmoPropConfig("lining_depth", (0, 1, 0)), + GizmoPropConfig("lining_thickness", (1, 0, 0)), + GizmoPropConfig("lining_to_panel_offset_x", (1, 0, 0)), + GizmoPropConfig("lining_to_panel_offset_y", (0, 1, 0)), + GizmoPropConfig("mullion_thickness", (1, 0, 0)), + GizmoPropConfig("first_mullion_offset", (1, 0, 0)), + GizmoPropConfig("second_mullion_offset", (1, 0, 0)), + GizmoPropConfig("transom_thickness", (0, 0, 1)), + GizmoPropConfig("first_transom_offset", (0, 0, 1)), + GizmoPropConfig("second_transom_offset", (0, 0, 1)), + ] + + @classmethod + def is_element_type(cls, element) -> bool: + return tool.Blender.Modifier.is_window(element) + + def get_props(self, obj): + return tool.Model.get_window_props(obj) + + def get_gizmo_prefs(self): + prefs = tool.Blender.get_addon_preferences() + return prefs.gizmos.window + + def should_hide_gizmo(self, attr_name, props): + """Window-specific visibility rules for gizmos.""" + if not props.is_editing: + return True + + has_mullion = self._has_mullion(props) + has_second_mullion = self._has_second_mullion(props) + has_transom = self._has_transom(props) + has_second_transom = self._has_second_transom(props) + + if attr_name == "mullion_thickness" and not has_mullion: + return True + if attr_name == "first_mullion_offset" and not has_mullion: + return True + if attr_name == "second_mullion_offset" and not has_second_mullion: + return True + if attr_name == "transom_thickness" and not has_transom: + return True + if attr_name == "first_transom_offset" and not has_transom: + return True + if attr_name == "second_transom_offset" and not has_second_transom: + return True + return False + + def get_gizmo_matrix_overall_height(self, props): + translation = Matrix.Translation(V_(props.overall_width - 0.05, props.lining_offset, props.overall_height)) + rotation = self.get_axis_rotation_matrix((0, 0, 1)) + return translation @ rotation + + def get_gizmo_matrix_overall_width(self, props): + translation = Matrix.Translation(V_(props.overall_width, props.lining_offset, props.overall_height - 0.05)) + rotation = self.get_axis_rotation_matrix((1, 0, 0)) + return translation @ rotation + + def get_gizmo_matrix_lining_depth(self, props): + translation = Matrix.Translation( + V_(props.overall_width / 2, props.lining_depth + props.lining_offset, props.overall_height) + ) + rotation = self.get_axis_rotation_matrix((0, 1, 0)) + return translation @ rotation + + def get_gizmo_matrix_lining_thickness(self, props): + translation = Matrix.Translation( + V_(props.lining_thickness, props.lining_depth / 2 + props.lining_offset, props.overall_height / 2) + ) + rotation = self.get_axis_rotation_matrix((1, 0, 0)) + return translation @ rotation + + @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_gizmo_matrix_lining_to_panel_offset_x(self, props): + y_full = self._get_lining_to_panel_offset_y_full(props) + translation = Matrix.Translation( + V_(props.lining_to_panel_offset_x, y_full + props.lining_offset, props.lining_thickness) + ) + rotation = self.get_axis_rotation_matrix((1, 0, 0)) + return translation @ rotation + + def get_gizmo_matrix_lining_to_panel_offset_y(self, props): + y_full = self._get_lining_to_panel_offset_y_full(props) + translation = Matrix.Translation( + V_(props.lining_to_panel_offset_x, y_full + props.frame_depth[0] + props.lining_offset, props.lining_thickness) + ) + rotation = self.get_axis_rotation_matrix((0, 1, 0)) + return translation @ rotation + + def get_gizmo_matrix_mullion_thickness(self, props): + translation = Matrix.Translation( + V_(props.first_mullion_offset + props.mullion_thickness / 2, props.lining_offset, props.overall_height / 2) + ) + rotation = self.get_axis_rotation_matrix((1, 0, 0)) + return translation @ rotation + + def get_gizmo_matrix_first_mullion_offset(self, props): + translation = Matrix.Translation( + V_(props.first_mullion_offset, props.lining_offset, props.overall_height / 2) + ) + rotation = self.get_axis_rotation_matrix((1, 0, 0)) + return translation @ rotation + + def get_gizmo_matrix_second_mullion_offset(self, props): + translation = Matrix.Translation( + V_(props.second_mullion_offset, props.lining_offset, props.overall_height / 2) + ) + rotation = self.get_axis_rotation_matrix((1, 0, 0)) + return translation @ rotation + + def get_gizmo_matrix_transom_thickness(self, props): + translation = Matrix.Translation( + V_(props.overall_width / 2, props.lining_offset, props.first_transom_offset + props.transom_thickness / 2) + ) + rotation = self.get_axis_rotation_matrix((0, 0, 1)) + return translation @ rotation + + def get_gizmo_matrix_first_transom_offset(self, props): + translation = Matrix.Translation( + V_(props.overall_width / 2, props.lining_offset, props.first_transom_offset) + ) + rotation = self.get_axis_rotation_matrix((0, 0, 1)) + return translation @ rotation + + def get_gizmo_matrix_second_transom_offset(self, props): + translation = Matrix.Translation( + V_(props.overall_width / 2, props.lining_offset, props.second_transom_offset) + ) + rotation = self.get_axis_rotation_matrix((0, 0, 1)) + return translation @ rotation + + def _has_mullion(self, props): + """Check if the window type uses mullions (vertical dividers).""" + window_type = props.window_type + return window_type in ( + "DOUBLE_PANEL_VERTICAL", + "TRIPLE_PANEL_BOTTOM", + "TRIPLE_PANEL_TOP", + "TRIPLE_PANEL_LEFT", + "TRIPLE_PANEL_RIGHT", + "TRIPLE_PANEL_VERTICAL", + ) + + def _has_second_mullion(self, props): + """Check if the window type uses a second mullion.""" + return props.window_type == "TRIPLE_PANEL_VERTICAL" + + def _has_transom(self, props): + """Check if the window type uses transoms (horizontal dividers).""" + window_type = props.window_type + return window_type in ( + "DOUBLE_PANEL_HORIZONTAL", + "TRIPLE_PANEL_BOTTOM", + "TRIPLE_PANEL_TOP", + "TRIPLE_PANEL_LEFT", + "TRIPLE_PANEL_RIGHT", + "TRIPLE_PANEL_HORIZONTAL", + ) + + def _has_second_transom(self, props): + """Check if the window type uses a second transom.""" + return props.window_type == "TRIPLE_PANEL_HORIZONTAL" + + def setup(self, context): + # Use base class methods for common gizmos + self.setup_property_gizmos(context) + self.setup_editing_gizmos(context) + + def refresh(self, context): + obj = context.active_object + if not obj: + return + + props = self.get_props(obj) + mw = obj.matrix_world + self.update_property_gizmos(mw, props) + self.update_editing_gizmos(mw, props) diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 3372808fcf..a5105a0947 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -253,6 +253,8 @@ class GizmoPreferencesDoor(bpy.types.PropertyGroup): lining_thickness: BoolProperty(name="Lining Thickness", default=True) transom_offset: BoolProperty(name="Transom Offset", default=True) transom_thickness: BoolProperty(name="Transom Thickness", default=True) + casing_thickness: BoolProperty(name="Casing Thickness", default=True) + casing_depth: BoolProperty(name="Casing Depth", default=True) swing_arc: BoolProperty(name="Swing Arc", default=True, description="Show door swing direction arc") flip_arc: BoolProperty(name="Flip Arc", default=True, description="Show flip door orientation arc") @@ -265,10 +267,76 @@ class GizmoPreferencesDoor(bpy.types.PropertyGroup): lining_thickness: bool transom_offset: bool transom_thickness: bool + casing_thickness: bool + casing_depth: bool swing_arc: bool flip_arc: bool +class GizmoPreferencesWindow(bpy.types.PropertyGroup): + """Property group for window gizmo visibility settings.""" + + overall_height: BoolProperty(name="Overall Height", default=True) + overall_width: BoolProperty(name="Overall Width", default=True) + lining_depth: BoolProperty(name="Lining Depth", default=True) + lining_thickness: BoolProperty(name="Lining Thickness", default=True) + lining_to_panel_offset_x: BoolProperty(name="Lining to Panel Offset X", default=True) + lining_to_panel_offset_y: BoolProperty(name="Lining to Panel Offset Y", default=True) + mullion_thickness: BoolProperty(name="Mullion Thickness", default=True) + first_mullion_offset: BoolProperty(name="First Mullion Offset", default=True) + second_mullion_offset: BoolProperty(name="Second Mullion Offset", default=True) + transom_thickness: BoolProperty(name="Transom Thickness", default=True) + first_transom_offset: BoolProperty(name="First Transom Offset", default=True) + second_transom_offset: BoolProperty(name="Second Transom Offset", default=True) + + if TYPE_CHECKING: + overall_height: bool + overall_width: bool + lining_depth: bool + lining_thickness: bool + lining_to_panel_offset_x: bool + lining_to_panel_offset_y: bool + mullion_thickness: bool + first_mullion_offset: bool + second_mullion_offset: bool + transom_thickness: bool + first_transom_offset: bool + second_transom_offset: bool + + +class GizmoPreferencesStair(bpy.types.PropertyGroup): + """Property group for stair gizmo visibility settings.""" + + width: BoolProperty(name="Width", default=True) + height: BoolProperty(name="Height", default=True) + tread_run: BoolProperty(name="Tread Run", default=True) + tread_depth: BoolProperty(name="Tread Depth", default=True) + nosing_length: BoolProperty(name="Nosing Length", default=True) + nosing_depth: BoolProperty(name="Nosing Depth", default=True) + total_length_target: BoolProperty(name="Total Length Target", default=True) + base_slab_depth: BoolProperty(name="Base Slab Depth", default=True) + top_slab_depth: BoolProperty(name="Top Slab Depth", default=True) + lock: BoolProperty(name="Total Length Lock", default=True) + plus: BoolProperty(name="Add Tread (+)", default=True) + minus: BoolProperty(name="Remove Tread (-)", default=True) + cycle: BoolProperty(name="Cycle Stair Type", default=True) + + if TYPE_CHECKING: + width: bool + height: bool + tread_run: bool + tread_depth: bool + nosing_length: bool + nosing_depth: bool + total_length_target: bool + base_slab_depth: bool + top_slab_depth: bool + lock: bool + plus: bool + minus: bool + cycle: bool + + class GizmoPreferences(bpy.types.PropertyGroup): """Property group for all gizmo visibility settings.""" @@ -278,10 +346,14 @@ class GizmoPreferences(bpy.types.PropertyGroup): description="Show interactive gizmos in the 3D viewport for parametric elements", ) door: bpy.props.PointerProperty(type=GizmoPreferencesDoor) + window: bpy.props.PointerProperty(type=GizmoPreferencesWindow) + stair: bpy.props.PointerProperty(type=GizmoPreferencesStair) if TYPE_CHECKING: draw_gizmos_in_3d_viewport: bool door: GizmoPreferencesDoor + window: GizmoPreferencesWindow + stair: GizmoPreferencesStair class DocPreferences(bpy.types.PropertyGroup): @@ -681,13 +753,40 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): ) def draw_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + layout.label(text="Toggle visibility of gizmos in editing mode") box = layout.box() bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters) + bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Window", self.draw_window_gizmo_parameters) + bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Stair", self.draw_stair_gizmo_parameters) def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + from bonsai.bim.module.model.door import GizmoDoorEdition + door_gizmos = self.gizmos.door + gizmo_prop_names = {p.attr_name for p in GizmoDoorEdition.gizmo_props} + gizmo_prop_names.update(("swing_arc", "flip_arc")) for prop in door_gizmos.__annotations__: - layout.prop(door_gizmos, prop) + if prop in gizmo_prop_names: + layout.prop(door_gizmos, prop) + + def draw_window_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + from bonsai.bim.module.model.window import GizmoWindowEdition + + window_gizmos = self.gizmos.window + gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.gizmo_props} + for prop in window_gizmos.__annotations__: + if prop in gizmo_prop_names: + layout.prop(window_gizmos, prop) + + def draw_stair_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + from bonsai.bim.module.model.stair import GizmoStairEdition + + stair_gizmos = self.gizmos.stair + gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.gizmo_props} + special_gizmo_names = {"lock", "plus", "minus"} + for prop in stair_gizmos.__annotations__: + if prop in gizmo_prop_names or prop in special_gizmo_names: + layout.prop(stair_gizmos, prop) def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "occurrence_name_style")