diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 68bb5773c5..f7adfb6319 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -21,7 +21,7 @@ import bpy import bpy.utils.previews import importlib from bpy_extras.io_utils import ImportHelper, ExportHelper -from . import handler, ui, prop, operator +from . import handler, ui, prop, operator, gizmo from typing import Union from collections.abc import Callable @@ -148,6 +148,8 @@ classes = [ ui.BIM_UL_clipping_plane, ui.BIM_UL_generic, ui.DocPreferences, + ui.GizmoPreferencesDoor, # Register before GizmoPreferences + ui.GizmoPreferences, # ui.DefaultParameters and ui.BIM_ADDON_preferences are registered separately after modules (see late_classes below) # Tabs panel ui.BIM_PT_tabs, @@ -202,6 +204,14 @@ classes = [ ui.BIM_PT_section_with_cappings, ui.BIM_PT_decorators_overlay, ui.BIM_PT_snappping, + # Gizmos + gizmo.GizmoArrow, + gizmo.GizmoCone, + gizmo.GizmoLock, + gizmo.GizmoArc, + gizmo.GizmoPen, + gizmo.GizmoValidate, + gizmo.GizmoCancel, ] for mod in modules.values(): diff --git a/src/bonsai/bonsai/bim/gizmo.py b/src/bonsai/bonsai/bim/gizmo.py new file mode 100644 index 0000000000..9c9816b03e --- /dev/null +++ b/src/bonsai/bonsai/bim/gizmo.py @@ -0,0 +1,770 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +""" +Shared gizmo components for reuse across modules. +""" + +import bpy +import math +from typing import Optional, Tuple, List +from mathutils import Vector +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 +import gpu +from gpu_extras.batch import batch_for_shader + + +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 + +ARROW_SHAFT_LENGTH = 0.8 +ARROW_HEAD_LENGTH = 0.2 +ARROW_WIDTH = 0.015 +ARROW_HEAD_WIDTH_MULTIPLIER = 10 +ARROW_CIRCLE_SEGMENTS = 8 + +CONE_LENGTH = 1.0 +CONE_RADIUS = 0.35 +CONE_SEGMENTS = 16 + +ARC_SEGMENTS = 24 +ARC_LINE_WIDTH = 0.015 + +PRECISION_MODE_MULTIPLIER = 0.1 + +class SnapManager: + """Manages snap point visualization and mesh snapping.""" + + def __init__(self): + self._snap_point: Optional[Tuple[float, float, float]] = None + self._draw_handler = None + self._shader = None + + def set_snap_point(self, point: Optional[Tuple[float, float, float]]) -> 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: + self._draw_handler = bpy.types.SpaceView3D.draw_handler_add( + self._draw, (), 'WINDOW', 'POST_VIEW' + ) + self._redraw_viewport() + + def clear(self) -> None: + """Clear snap point and unregister handler.""" + self._snap_point = None + if self._draw_handler is not None: + bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, 'WINDOW') + self._draw_handler = None + self._redraw_viewport() + + def _draw(self) -> None: + """Draw snap point circles.""" + if self._snap_point is None: + return + + if self._shader is None: + 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) + + 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) + + gpu.state.line_width_set(1.0) + + @staticmethod + def _redraw_viewport() -> None: + """Force 3D viewport redraw.""" + for area in bpy.context.screen.areas: + if area.type == 'VIEW_3D': + area.tag_redraw() + + @staticmethod + def snap_to_mesh( + location: Vector, + context: bpy.types.Context, + axis_vector: Vector, + active_obj: bpy.types.Object + ) -> Vector: + """Snap a location to the nearest mesh element if snapping is enabled. + + Args: + location: The world space location to snap + context: The Blender context + axis_vector: The axis direction of the gizmo + active_obj: The active object to exclude from snapping + + Returns: + The snapped location or original location if snapping is disabled + """ + tool_settings = context.scene.tool_settings + + if not tool_settings.use_snap: + return location + + snap_elements = tool_settings.snap_elements_base + + mesh_objects = [ + obj + for obj in context.visible_objects + if obj.type == "MESH" and obj != active_obj and obj.visible_get() + ] + + 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 + + if not nearby_objects: + return location + + closest_point = None + closest_distance = float("inf") + + for obj in nearby_objects: + if not obj.data.vertices: + continue + + depsgraph = context.evaluated_depsgraph_get() + obj_eval = obj.evaluated_get(depsgraph) + mesh = obj_eval.to_mesh() + + if not mesh.vertices: + obj_eval.to_mesh_clear() + continue + + 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 + + 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 + + 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 + + obj_eval.to_mesh_clear() + + if closest_point and closest_distance < SNAP_MAX_RADIUS: + return closest_point + + return location + + +_snap_manager = SnapManager() + + +def set_snap_point(point: Optional[Tuple[float, float, float]]) -> None: + _snap_manager.set_snap_point(point) + + +def clear_snap_point() -> None: + _snap_manager.clear() + + +def snap_to_mesh( + location: Vector, + context: bpy.types.Context, + axis_vector: Vector, + active_obj: bpy.types.Object +) -> Vector: + return _snap_manager.snap_to_mesh(location, context, axis_vector, active_obj) + + +def generate_circle_vertices( + center: Tuple[float, float, float], + radius: float, + segments: int, + plane: str = 'XY' +) -> List[Tuple[float, float, float]]: + """Generate circle vertices in specified plane. + + Args: + center: (x, y, z) tuple for circle center + radius: Circle radius + segments: Number of segments + plane: 'XY', 'XZ', or 'YZ' + + Returns: + List of (x, y, z) vertices + """ + vertices = [] + for i in range(segments + 1): + angle = (2 * math.pi * i) / segments + cos_a = radius * math.cos(angle) + sin_a = radius * math.sin(angle) + + if plane == 'XY': + vertices.append((center[0] + cos_a, center[1] + sin_a, center[2])) + elif plane == 'XZ': + vertices.append((center[0] + cos_a, center[1], center[2] + sin_a)) + else: + vertices.append((center[0], center[1] + cos_a, center[2] + sin_a)) + + return vertices + + +def create_quarter_circle_arc( + radius: float = 1.0, + segments: int = ARC_SEGMENTS, + direction: str = "LEFT", + line_width: float = ARC_LINE_WIDTH +) -> Tuple[Tuple[float, float, float], ...]: + """Create a quarter 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°) + line_width: Width of the arc line in both perpendicular directions + + Returns: + Tuple of arc triangles for drawing geometry visible from all angles + """ + half_width = line_width / 2 + + arc_points = [] + if direction == "LEFT": + for i in range(segments + 1): + angle = (math.pi / 2) * (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) + x = -radius * math.cos(angle) + y = radius * math.sin(angle) + arc_points.append((x, y)) + + arc_triangles = [] + for i in range(len(arc_points) - 1): + x1, y1 = arc_points[i] + x2, y2 = arc_points[i + 1] + + 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 + + arc_triangles.extend([ + (x1 + px, y1 + py, 0.0), + (x1 - px, y1 - py, 0.0), + (x2 + px, y2 + py, 0.0), + ]) + arc_triangles.extend([ + (x2 + px, y2 + py, 0.0), + (x1 - px, y1 - py, 0.0), + (x2 - px, y2 - py, 0.0), + ]) + + arc_triangles.extend([ + (x1, y1, -half_width), + (x2, y2, -half_width), + (x1, y1, +half_width), + ]) + arc_triangles.extend([ + (x1, y1, +half_width), + (x2, y2, -half_width), + (x2, y2, +half_width), + ]) + + return tuple(arc_triangles) + + +class GizmoMovable(bpy.types.Gizmo): + """Base class for movable gizmos with snapping and precision controls. + + This class provides common functionality for gizmos that can be dragged + to modify values, including: + - Snap to mesh support (toggle with Ctrl) + - Precision mode (hold Shift for 10x slower movement) + - Header text with value and modifier hints + - Consistent invoke/exit/modal behavior + """ + + __slots__ = ( + "custom_shape", + "init_value", + "move_get_cb", + "move_set_cb", + "axis", + "local_axis", + "start_location", + "active_obj", + "initial_snap_state", + ) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set: + self.init_value = self.move_get_cb() if self.move_get_cb else 0.0 + self.start_location = self.matrix_basis.translation.copy() + self.active_obj = context.active_object + self.initial_snap_state = context.scene.tool_settings.use_snap + return {"RUNNING_MODAL"} + + def exit(self, context: bpy.types.Context, cancel: bool) -> None: + context.area.header_text_set(None) + if cancel and self.move_set_cb: + self.move_set_cb(self.init_value) + if hasattr(self, "initial_snap_state"): + context.scene.tool_settings.use_snap = self.initial_snap_state + clear_snap_point() + + def get_axis_direction(self) -> Vector: + return self.axis + + def modal(self, context: bpy.types.Context, event: bpy.types.Event, tweak) -> set: + region = context.region + rv3d = context.region_data + tool_settings = context.scene.tool_settings + + tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state + + current_coord = (event.mouse_region_x, event.mouse_region_y) + view_origin = region_2d_to_origin_3d(region, rv3d, current_coord) + view_direction = region_2d_to_vector_3d(region, rv3d, current_coord) + + axis_direction = self.get_axis_direction() + + result = intersect_line_line( + view_origin, view_origin + view_direction * 1000, + self.start_location, self.start_location + axis_direction * 1000 + ) + 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) + if snapped_pos != current_3d: + delta = (snapped_pos - self.start_location).dot(axis_direction) + set_snap_point(snapped_pos) + else: + clear_snap_point() + else: + clear_snap_point() + + if event.shift: + delta *= PRECISION_MODE_MULTIPLIER + + if self.move_set_cb: + self.move_set_cb(self.init_value + delta) + + self._update_header(context, self.init_value + delta, tool_settings.use_snap, event.shift) + + return {"RUNNING_MODAL"} + + def _update_header( + self, + context: bpy.types.Context, + value: float, + is_snapping: bool, + is_precision: bool + ) -> None: + header_text = f"Value: {value:.3f}m" + hints = [] + if is_snapping: + hints.append("Snapping: ON") + if is_precision: + hints.append("Precision Mode (0.1x)") + hints.extend(["Ctrl: Toggle Snap", "Shift: Precision"]) + + if hints: + header_text += " | " + " | ".join(hints) + context.area.header_text_set(header_text) + + +class GizmoLock(bpy.types.Gizmo): + """Reusable lock icon gizmo that switches between closed and open states.""" + + bl_idname = "VIEW3D_GT_lock" + + __slots__ = ( + "custom_shape_closed", + "custom_shape_open", + "prop_path", + ) + + 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), + ) + + 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), + ) + + def get_custom_shape(self, context: bpy.types.Context): + obj = context.active_object + if not obj: + return self.custom_shape_closed + + try: + is_open = obj.path_resolve(self.prop_path) + return self.custom_shape_open if is_open else self.custom_shape_closed + except: + return self.custom_shape_closed + + def setup(self) -> None: + self.custom_shape_closed = self.new_custom_shape("TRIS", self.tris_closed) + self.custom_shape_open = self.new_custom_shape("TRIS", self.tris_open) + + def draw(self, context: bpy.types.Context) -> None: + self.draw_custom_shape(self.get_custom_shape(context)) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self.draw_custom_shape(self.get_custom_shape(context), select_id=select_id) + + +class GizmoArc(bpy.types.Gizmo): + """Reusable quarter circle arc gizmo.""" + + bl_idname = "VIEW3D_GT_arc" + + __slots__ = ( + "custom_shape_left", + "custom_shape_right", + "prop_path", + ) + + 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") + + 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): + obj = context.active_object + if not obj: + return self.custom_shape_left + + try: + direction_value = obj.path_resolve(self.prop_path) + if "RIGHT" in str(direction_value): + return self.custom_shape_right + except: + pass + + return self.custom_shape_left + + def draw(self, context: bpy.types.Context) -> None: + self.draw_custom_shape(self._get_shape_for_direction(context)) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self.draw_custom_shape(self._get_shape_for_direction(context), select_id=select_id) + + +class GizmoPen(bpy.types.Gizmo): + """Reusable pen/edit icon gizmo for entering edit mode.""" + + bl_idname = "VIEW3D_GT_pen" + + __slots__ = ("custom_shape",) + + tris = ( + (-0.07595771551132202, -0.2948460578918457, 0.0), + (0.16886109113693237, 0.23203276097774506, 0.0), + (0.062240585684776306, 0.28157487511634827, 0.0), + (0.07201281189918518, 0.30260589718818665, 0.0), + (0.21042980253696442, 0.321493536233902, 0.0), + (0.17863331735134125, 0.25306373834609985, 0.0), + (0.062240585684776306, 0.28157487511634827, 0.0), + (-0.1825782209634781, -0.2453039139509201, 0.0), + (-0.07595771551132202, -0.2948460578918457, 0.0), + (-0.1825782209634781, -0.2453039139509201, 0.0), + (-0.19114767014980316, -0.4032624065876007, 0.0), + (-0.07595771551132202, -0.2948460578918457, 0.0), + (0.07201281189918518, 0.30260589718818665, 0.0), + (0.10380929708480835, 0.371035635471344, 0.0), + (0.21042980253696442, 0.321493536233902, 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 GizmoValidate(bpy.types.Gizmo): + """Reusable validate/checkmark icon gizmo for confirming edits.""" + + bl_idname = "VIEW3D_GT_validate" + + __slots__ = ("custom_shape",) + + tris = ( + (0.36775994300842285, 0.205583393573761, 0.0), + (0.030080009251832962, -0.1881658434867859, 0.0), + (0.030080009251832962, -0.3380376696586609, 0.0), + (-0.22017201781272888, -0.16090886294841766, 0.0), + (0.030080009251832962, -0.1881658434867859, 0.0), + (-0.22017201781272888, -0.011037036776542664, 0.0), + (0.36775994300842285, 0.205583393573761, 0.0), + (0.36775994300842285, 0.355455219745636, 0.0), + (0.030080009251832962, -0.1881658434867859, 0.0), + (-0.22017201781272888, -0.16090886294841766, 0.0), + (0.030080009251832962, -0.3380376696586609, 0.0), + (0.030080009251832962, -0.1881658434867859, 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 GizmoCancel(bpy.types.Gizmo): + """Reusable cancel/X icon gizmo for canceling edits.""" + + bl_idname = "VIEW3D_GT_cancel" + + __slots__ = ("custom_shape",) + + tris = ( + (0.0, 0.048707593232393265, 0.0), + (0.0, -0.048707593232393265, 0.0), + (0.048707593232393265, 0.0, 0.0), + (0.0, 0.048707593232393265, 0.0), + (-0.21918421983718872, 0.2678918242454529, 0.0), + (-0.048707593232393265, 0.0, 0.0), + (-0.21918421983718872, 0.2678918242454529, 0.0), + (-0.2678918242454529, 0.21918421983718872, 0.0), + (-0.048707593232393265, 0.0, 0.0), + (-0.048707593232393265, 0.0, 0.0), + (-0.2678918242454529, -0.21918421983718872, 0.0), + (-0.21918421983718872, -0.2678918242454529, 0.0), + (0.2678918242454529, 0.21918421983718872, 0.0), + (0.21918421983718872, 0.2678918242454529, 0.0), + (0.0, 0.048707593232393265, 0.0), + (0.21918421983718872, -0.2678918242454529, 0.0), + (0.2678918242454529, -0.21918421983718872, 0.0), + (0.048707593232393265, 0.0, 0.0), + (0.048707593232393265, 0.0, 0.0), + (0.2678918242454529, 0.21918421983718872, 0.0), + (0.0, 0.048707593232393265, 0.0), + (0.0, 0.048707593232393265, 0.0), + (-0.048707593232393265, 0.0, 0.0), + (0.0, -0.048707593232393265, 0.0), + (-0.048707593232393265, 0.0, 0.0), + (-0.21918421983718872, -0.2678918242454529, 0.0), + (0.0, -0.048707593232393265, 0.0), + (0.0, -0.048707593232393265, 0.0), + (0.21918421983718872, -0.2678918242454529, 0.0), + (0.048707593232393265, 0.0, 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 GizmoArrow(GizmoMovable): + """Arrow gizmo for directional value editing.""" + + bl_idname = "BIM_GT_gizmo_arrow" + bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) + + def _get_arrow_triangles(self) -> Tuple[Tuple[float, float, float], ...]: + """Generate arrow geometry along +X axis.""" + triangles = [] + + triangles.extend([ + (0, -ARROW_WIDTH, 0), + (ARROW_SHAFT_LENGTH, -ARROW_WIDTH, 0), + (0, +ARROW_WIDTH, 0), + ]) + triangles.extend([ + (0, +ARROW_WIDTH, 0), + (ARROW_SHAFT_LENGTH, -ARROW_WIDTH, 0), + (ARROW_SHAFT_LENGTH, +ARROW_WIDTH, 0), + ]) + + triangles.extend([ + (0, 0, -ARROW_WIDTH), + (ARROW_SHAFT_LENGTH, 0, -ARROW_WIDTH), + (0, 0, +ARROW_WIDTH), + ]) + triangles.extend([ + (0, 0, +ARROW_WIDTH), + (ARROW_SHAFT_LENGTH, 0, -ARROW_WIDTH), + (ARROW_SHAFT_LENGTH, 0, +ARROW_WIDTH), + ]) + + head_width = ARROW_WIDTH * ARROW_HEAD_WIDTH_MULTIPLIER + triangles.extend([ + (ARROW_SHAFT_LENGTH, -head_width, 0), + (ARROW_SHAFT_LENGTH + ARROW_HEAD_LENGTH, 0, 0), + (ARROW_SHAFT_LENGTH, +head_width, 0), + ]) + + triangles.extend([ + (ARROW_SHAFT_LENGTH, 0, -head_width), + (ARROW_SHAFT_LENGTH + ARROW_HEAD_LENGTH, 0, 0), + (ARROW_SHAFT_LENGTH, 0, +head_width), + ]) + + for i in range(ARROW_CIRCLE_SEGMENTS): + angle1 = (2 * math.pi * i) / ARROW_CIRCLE_SEGMENTS + angle2 = (2 * math.pi * (i + 1)) / ARROW_CIRCLE_SEGMENTS + + y1 = head_width * math.cos(angle1) + z1 = head_width * math.sin(angle1) + y2 = head_width * math.cos(angle2) + z2 = head_width * math.sin(angle2) + + triangles.extend([ + (ARROW_SHAFT_LENGTH, 0, 0), + (ARROW_SHAFT_LENGTH, y1, z1), + (ARROW_SHAFT_LENGTH, y2, z2), + ]) + + return tuple(triangles) + + def setup(self) -> None: + self.custom_shape = self.new_custom_shape("TRIS", self._get_arrow_triangles()) + + 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 GizmoCone(GizmoMovable): + """Cone gizmo for directional value editing with local axis support.""" + + bl_idname = "BIM_GT_gizmo_cone" + bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) + + def get_axis_direction(self) -> Vector: + if hasattr(self, "local_axis") and self.active_obj: + obj_rotation = self.active_obj.matrix_world.to_3x3() + axis_direction = obj_rotation @ self.local_axis + axis_direction.normalize() + return axis_direction + return self.axis + + def _get_cone_triangles(self) -> Tuple[Tuple[float, float, float], ...]: + """Generate cone geometry along +X axis.""" + triangles = [] + cone_tip_x = CONE_LENGTH + + for i in range(CONE_SEGMENTS): + angle1 = (2 * math.pi * i) / CONE_SEGMENTS + angle2 = (2 * math.pi * (i + 1)) / CONE_SEGMENTS + + y1 = CONE_RADIUS * math.cos(angle1) + z1 = CONE_RADIUS * math.sin(angle1) + y2 = CONE_RADIUS * math.cos(angle2) + z2 = CONE_RADIUS * math.sin(angle2) + + triangles.extend([ + (cone_tip_x, 0, 0), + (0, y1, z1), + (0, y2, z2), + ]) + + triangles.extend([ + (0, 0, 0), + (0, y2, z2), + (0, y1, z1), + ]) + + return tuple(triangles) + + def setup(self) -> None: + self.custom_shape = self.new_custom_shape("TRIS", self._get_cone_triangles()) + + 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) diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index fa9ce7cafa..dd4bfc0c3b 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -178,6 +178,8 @@ classes = ( door.FinishEditingDoor, door.EnableEditingDoor, door.RemoveDoor, + door.ToggleDoorSwing, + door.GizmoDoorEdition, railing.BIM_OT_add_railing, railing.CopyRailingParameters, railing.AddRailing, diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index d73a16deff..945cb64687 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -34,6 +34,7 @@ import bonsai.core.geometry as core import bonsai.core.root from bonsai.bim.module.model.window import create_bm_window, create_bm_box +import math from mathutils import Vector, Matrix from typing import Union, Any, Optional @@ -278,7 +279,7 @@ def create_bm_door_lining( new_faces = [bm.faces.new([new_verts[vi] for vi in face[1]]) for face in faces] extruded = bmesh.ops.extrude_face_region(bm, geom=new_faces) - extrusion_vector = Vector((0, 1, 0)) * depth + extrusion_vector = V_(0, 1, 0) * depth translate_verts = [v for v in extruded["geom"] if isinstance(v, bmesh.types.BMVert)] bmesh.ops.translate(bm, vec=extrusion_vector, verts=translate_verts) @@ -487,10 +488,10 @@ class BIM_OT_add_door(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 door.") @@ -553,7 +554,7 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator): ) update_door_modifier_representation(obj) - def _execute(self, context): + def _execute(self, context: bpy.types.Context) -> set[str]: for obj in tool.Blender.get_selected_objects(): if not tool.Blender.Modifier.is_eligible_for_door_modifier(obj): continue @@ -564,6 +565,7 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator): class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.cancel_editing_door" bl_label = "Cancel Editing Door on Selected Objects" + bl_description = "Cancel editing and revert door parameters to their previous values" bl_options = {"REGISTER", "UNDO"} def cancel_editing_door_on_object(self, obj: bpy.types.Object) -> None: @@ -598,6 +600,7 @@ class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator): class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.finish_editing_door" bl_label = "Finish Editing Door on Selected Objects" + bl_description = "Apply changes and finish editing door parameters" bl_options = {"REGISTER", "UNDO"} def finish_editing_door_on_object(self, obj): @@ -634,6 +637,7 @@ class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator): class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_door" bl_label = "Enable Editing Door on Selected Objects" + bl_description = "Enter edit mode to modify door parameters interactively" bl_options = {"REGISTER", "UNDO"} def edit_door_on_obj(self, obj): @@ -677,3 +681,320 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator): for obj in tool.Blender.get_selected_objects(): self.remove_door_on_object(obj) return {"FINISHED"} + + +class ToggleDoorSwing(bpy.types.Operator, tool.Ifc.Operator): + """Toggle door swing direction between LEFT and RIGHT""" + + bl_idname = "bim.toggle_door_swing" + bl_label = "Toggle Door Swing" + bl_options = {"REGISTER", "UNDO"} + + 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) + current_type = props.door_type + + 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") + + return {"FINISHED"} + + +class GizmoDoorEdition(bpy.types.GizmoGroup): + 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 + 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)}, + ] + + @classmethod + def poll(cls, context): + """Show gizmo only when a single door object 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 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 get_gizmo_matrix_overall_height(self, props): + translation = Matrix.Translation(V_(props.overall_width - 0.05, 0.0, 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)) + rotation = self.get_axis_rotation_matrix((1, 0, 0)) + return translation @ rotation + + def get_gizmo_matrix_threshold_thickness(self, props): + translation = Matrix.Translation( + V_( + props.overall_width / 2, + props.threshold_offset + props.threshold_depth / 2, + props.threshold_thickness, + ) + ) + rotation = self.get_axis_rotation_matrix((0, 0, 1)) + return translation @ rotation + + def get_gizmo_matrix_threshold_depth(self, props): + translation = Matrix.Translation( + V_(props.overall_width / 2, props.threshold_depth, props.threshold_thickness / 2) + ) + rotation = self.get_axis_rotation_matrix((0, 1, 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 + + def get_gizmo_matrix_transom_offset(self, props): + translation = Matrix.Translation(V_(props.overall_width / 2, 0.0, 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) + ) + rotation = self.get_axis_rotation_matrix((0, 0, 1)) + return translation @ rotation + + def setup(self, context): + 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") + + # Flip arc gizmo - mirrors the swing arc along X axis, flips door 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.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) + mw = obj.matrix_world + self.update_arrows(mw, props) + self.update_swing_gizmo(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): + """Update swing gizmo position and color based on editing state.""" + 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 + self.gizmo_flip_arc.hide = not props.is_editing or not door_gizmo_prefs.flip_arc + + # If both are hidden, no need to calculate transforms + if self.gizmo_door_type.hide and self.gizmo_flip_arc.hide: + return + + # 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) + + 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 diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index dc9285114e..303ce43d41 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -928,14 +928,14 @@ class BIMDoorProperties(PropertyGroup): lining_depth: bpy.props.FloatProperty( name="Lining Depth", default=0.050, - min=0, + min=0.001, subtype="DISTANCE", update=update_door, ) lining_thickness: bpy.props.FloatProperty( name="Lining Thickness", default=0.050, - min=0, + min=0.001, subtype="DISTANCE", update=update_door, ) @@ -983,7 +983,7 @@ class BIMDoorProperties(PropertyGroup): casing_depth: bpy.props.FloatProperty( name="Casing Depth", default=0.005, - min=0, + min=0.001, subtype="DISTANCE", update=update_door, ) @@ -999,7 +999,7 @@ class BIMDoorProperties(PropertyGroup): threshold_depth: bpy.props.FloatProperty( name="Threshold Depth", default=0.1, - min=0, + min=0.001, subtype="DISTANCE", update=update_door, ) diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index d77ca54223..3372808fcf 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -242,6 +242,48 @@ class BIM_UL_generic(bpy.types.UIList): layout.label(text="", translate=False) +class GizmoPreferencesDoor(bpy.types.PropertyGroup): + """Property group for door gizmo visibility settings.""" + + overall_height: BoolProperty(name="Overall Height", default=True) + overall_width: BoolProperty(name="Overall Width", default=True) + threshold_thickness: BoolProperty(name="Threshold Thickness", default=True) + threshold_depth: BoolProperty(name="Threshold Depth", default=True) + lining_depth: BoolProperty(name="Lining Depth", default=True) + lining_thickness: BoolProperty(name="Lining Thickness", default=True) + transom_offset: BoolProperty(name="Transom Offset", default=True) + transom_thickness: BoolProperty(name="Transom Thickness", default=True) + swing_arc: BoolProperty(name="Swing Arc", default=True, description="Show door swing direction arc") + flip_arc: BoolProperty(name="Flip Arc", default=True, description="Show flip door orientation arc") + + if TYPE_CHECKING: + overall_height: bool + overall_width: bool + threshold_thickness: bool + threshold_depth: bool + lining_depth: bool + lining_thickness: bool + transom_offset: bool + transom_thickness: bool + swing_arc: bool + flip_arc: bool + + +class GizmoPreferences(bpy.types.PropertyGroup): + """Property group for all gizmo visibility settings.""" + + draw_gizmos_in_3d_viewport: BoolProperty( + name="Draw Gizmos In 3D Viewport", + default=True, + description="Show interactive gizmos in the 3D viewport for parametric elements", + ) + door: bpy.props.PointerProperty(type=GizmoPreferencesDoor) + + if TYPE_CHECKING: + draw_gizmos_in_3d_viewport: bool + door: GizmoPreferencesDoor + + class DocPreferences(bpy.types.PropertyGroup): sheets_dir: StringProperty( default=os.path.join("sheets") + os.path.sep, @@ -515,6 +557,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): name="Occurrence Name Function", description="Code that will be evaluated to generate occurrence name for CUSTOM occurrence name style", ) + gizmos: bpy.props.PointerProperty(type=GizmoPreferences) def update_data_dir(self, context: bpy.types.Context) -> None: import bonsai.bim.schema @@ -580,6 +623,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): should_always_cache: bool occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"] occurrence_name_function: str + gizmos: GizmoPreferences data_dir: str cache_dir: str pset_dir: str @@ -627,6 +671,24 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): layout.prop(self, "should_use_snap") layout.prop(self, "should_play_chaching_sound") + layout.prop(self.gizmos, "draw_gizmos_in_3d_viewport") + if self.gizmos.draw_gizmos_in_3d_viewport: + bonsai.bim.helper.draw_expandable_panel( + layout, + context, + "Gizmos Parameters", + self.draw_gizmo_parameters, + ) + + def draw_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + box = layout.box() + bonsai.bim.helper.draw_expandable_panel(box, context, "Parametric Door", self.draw_door_gizmo_parameters) + + def draw_door_gizmo_parameters(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: + door_gizmos = self.gizmos.door + for prop in door_gizmos.__annotations__: + layout.prop(door_gizmos, prop) + def draw_model_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "occurrence_name_style") if self.occurrence_name_style == "CUSTOM":