diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 7e51aea545..3d865de16e 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, gizmo +from . import handler, ui, prop, operator from typing import Union from collections.abc import Callable @@ -206,19 +206,6 @@ classes = [ ui.BIM_PT_section_with_cappings, ui.BIM_PT_decorators_overlay, ui.BIM_PT_snappping, - # Gizmos - gizmo.BIM_OT_gizmo_value_input, - gizmo.GizmoArrow, - gizmo.GizmoArrow2D, - gizmo.GizmoCone, - gizmo.GizmoLock, - gizmo.GizmoArc, - 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 deleted file mode 100644 index ded59fecdd..0000000000 --- a/src/bonsai/bonsai/bim/gizmo.py +++ /dev/null @@ -1,1981 +0,0 @@ -# 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. -""" - -__all__ = [ - # Dataclasses - "GizmoPropConfig", - "NumericInputState", - # Functions - "set_snap_point", - "clear_snap_point", - "snap_to_mesh", - "build_snap_cache", - "clear_snap_cache", - "get_billboard_rotation", - "get_camera_direction", - "generate_circle_vertices", - "create_circle_arc", - # Operators - "BIM_OT_gizmo_value_input", - # Gizmo classes - "GizmoMovable", - "GizmoLock", - "GizmoArc", - "GizmoPen", - "GizmoValidate", - "GizmoCancel", - "GizmoPlus", - "GizmoMinus", - "GizmoCycle", - "GizmoArrow", - "GizmoArrow2D", - "GizmoCone", - # Mixin classes - "BaseParametricGizmoGroup", -] - -from typing import Any - -import bpy -import math -import numpy as np -from dataclasses import dataclass -from mathutils import Vector, Matrix -from mathutils.kdtree import KDTree -from mathutils.geometry import intersect_line_line -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 -from bonsai.tool.unit import parse_distance_string - - -SNAP_POINT_SIZE = 10.0 -SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0) -SNAP_MAX_RADIUS = 5.0 -SNAP_SCREEN_DISTANCE = 15 # Maximum screen-space distance for snapping (in pixels) -SNAP_WORLD_DISTANCE = 0.2 # Maximum world-space distance for snapping (in meters) -SNAP_KD_CANDIDATES = 16 # Number of KD-tree candidates (3D nearest may differ from screen nearest) - -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 - -RAY_CAST_DISTANCE = 1000 # Distance to extend rays for intersection calculations -DEFAULT_POINT_SIZE = 1.0 # Default GPU point size - -# Characters allowed for keyboard numeric input (supports units and formulas) -_DIGITS = set("0123456789") -_OPERATORS = {".", "-", "+", "*", "/"} -_METRIC_UNITS = {"m", "c", "d"} # m, cm, dm, mm -_IMPERIAL_UNITS = {"f", "t", "i", "n", "'", '"'} # ft, in, ', " -_SPECIAL = {"=", " "} # Formula prefix, spaces - -NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL - -# Module-level storage for gizmo callback (Blender ID properties don't support functions) -_gizmo_value_input_callback: dict[str, Any] = {} - - -@dataclass -class SnapCache: - """Unified snap cache with combined KD-tree for vertex snapping.""" - - # Combined KD-tree with all world vertices from all objects - kd_tree: KDTree - # All world vertices indexed by global vertex index - all_vertices: list[Vector] - - -@dataclass -class NumericInputState: - """State for keyboard numeric input during gizmo operations.""" - - characters: list[str] - parsed_value: float - is_active: bool - is_valid: bool - - @classmethod - def create_default(cls) -> "NumericInputState": - return cls(characters=[], parsed_value=0.0, is_active=False, is_valid=True) - - def reset(self) -> None: - self.characters.clear() - self.parsed_value = 0.0 - self.is_active = False - self.is_valid = True - - def get_input_string(self) -> str: - return "".join(self.characters) - - def is_relative_mode(self) -> bool: - input_str = self.get_input_string() - return input_str.startswith("+") or input_str.startswith("-") - - def calculate_final_value(self, init_value: float, invert_delta: bool = False) -> float: - if self.is_relative_mode(): - delta = self.parsed_value - if invert_delta: - delta = -delta - return init_value + delta - return self.parsed_value - - def parse(self) -> None: - """Parse the current input string and update parsed_value and is_valid.""" - if not self.characters: - self.parsed_value = 0.0 - self.is_valid = True - return - - input_str = self.get_input_string() - is_valid, value = parse_distance_string(input_str) - - if is_valid: - self.parsed_value = value - self.is_valid = True - else: - try: - self.parsed_value = float(input_str) - self.is_valid = True - except ValueError: - self.parsed_value = 0.0 - self.is_valid = False - - -@dataclass -class GizmoPropConfig: - """Configuration for a gizmo property.""" - - attr_name: str - axis: tuple[int, int, int] - invert_delta: bool = False - delta_scale: float = 1.0 # Multiplier for the delta (e.g., 2.0 for symmetric thickness properties) - - -class SnapManager: - """Manages snap point visualization and mesh snapping with caching.""" - - def __init__(self): - self._snap_point: tuple[float, float, float] | Vector | None = None - self._draw_handler = None - self._shader = None - self._snap_cache: SnapCache | None = 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: - 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 as a dot.""" - 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_POINT_COLOR) - gpu.state.point_size_set(SNAP_POINT_SIZE) - - batch = batch_for_shader(self._shader, "POINTS", {"pos": [self._snap_point]}) - batch.draw(self._shader) - - gpu.state.point_size_set(DEFAULT_POINT_SIZE) - - @staticmethod - def _redraw_viewport() -> None: - """Force 3D viewport redraw.""" - for area in bpy.context.screen.areas: - if area.type == "VIEW_3D": - area.tag_redraw() - - def build_snap_cache(self, context: bpy.types.Context, active_obj: bpy.types.Object) -> None: - """Build unified cache with combined KD-tree for vertex snapping. - - Uses foreach_get for fast vertex data extraction and NumPy for - batch matrix transformation. - """ - self._snap_cache = None - - 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 - - depsgraph = context.evaluated_depsgraph_get() - all_vertices: list[Vector] = [] - - for obj in mesh_objects: - mesh_data = obj.data - if not hasattr(mesh_data, "vertices") or not mesh_data.vertices: - continue - - obj_eval = obj.evaluated_get(depsgraph) - mesh = obj_eval.to_mesh() - - try: - vertex_count = len(mesh.vertices) - if vertex_count == 0: - continue - - coords = np.empty(vertex_count * 3, dtype=np.float32) - mesh.vertices.foreach_get("co", coords) # type: ignore[arg-type] - coords = coords.reshape(-1, 3) - - matrix = np.array(obj_eval.matrix_world, dtype=np.float32) - ones = np.ones((vertex_count, 1), dtype=np.float32) - coords_h = np.hstack([coords, ones]) - world_coords = (coords_h @ matrix.T)[:, :3] - - all_vertices.extend(Vector(co) for co in world_coords) - finally: - obj_eval.to_mesh_clear() - - if not all_vertices: - return - - kd_tree = KDTree(len(all_vertices)) - for i, v in enumerate(all_vertices): - kd_tree.insert(v, i) - kd_tree.balance() - - self._snap_cache = SnapCache( - kd_tree=kd_tree, - all_vertices=all_vertices, - ) - - def clear_snap_cache(self) -> None: - self._snap_cache = None - - @staticmethod - def _calc_snap_distance_sq( - point_3d: Vector, - location: Vector, - mouse_vec: Vector | None, - region: bpy.types.Region | None, - rv3d: bpy.types.RegionView3D | None, - ) -> float: - """Calculate squared distance - screen-space if mouse coords available, else world-space.""" - if mouse_vec 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 (mouse_vec - point_2d).length_squared - return float("inf") - return (point_3d - location).length_squared - - @staticmethod - def _find_closest_vertex( - world_vertices: list[Vector], - location: Vector, - mouse_vec: Vector | None, - region: bpy.types.Region | None, - rv3d: bpy.types.RegionView3D | None, - closest_point: Vector | None, - closest_dist_sq: float, - kd_tree: KDTree | None = None, - ) -> tuple[Vector | None, float]: - """Find the closest vertex to snap to using KD-tree if available.""" - if kd_tree is not None: - for _, idx, _ in kd_tree.find_n(location, SNAP_KD_CANDIDATES): - v_co = world_vertices[idx] - dist_sq = SnapManager._calc_snap_distance_sq(v_co, location, mouse_vec, region, rv3d) - if dist_sq < closest_dist_sq: - closest_dist_sq = dist_sq - closest_point = v_co - else: - for v_co in world_vertices: - dist_sq = SnapManager._calc_snap_distance_sq(v_co, location, mouse_vec, region, rv3d) - if dist_sq < closest_dist_sq: - closest_dist_sq = dist_sq - closest_point = v_co - return closest_point, closest_dist_sq - - @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.""" - radius_sq = SNAP_MAX_RADIUS * SNAP_MAX_RADIUS - nearby_objects = [] - - for obj in mesh_objects: - bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] - if not bbox_corners: - continue - - bbox_min = Vector(( - min(c.x for c in bbox_corners), - min(c.y for c in bbox_corners), - min(c.z for c in bbox_corners), - )) - bbox_max = Vector(( - max(c.x for c in bbox_corners), - max(c.y for c in bbox_corners), - max(c.z for c in bbox_corners), - )) - - closest = Vector(( - max(bbox_min.x, min(location.x, bbox_max.x)), - max(bbox_min.y, min(location.y, bbox_max.y)), - max(bbox_min.z, min(location.z, bbox_max.z)), - )) - - if (location - closest).length_squared <= radius_sq: - nearby_objects.append(obj) - - return nearby_objects - - def snap_to_mesh( - self, - location: Vector, - context: bpy.types.Context, - active_obj: bpy.types.Object, - mouse_coords: tuple[float, float] | None = None, - ) -> Vector: - """Snap a location to the nearest vertex if snapping is enabled. - - Only vertex snapping is supported. Returns the original location if - snapping is disabled or VERTEX is not in the snap elements. - """ - tool_settings = context.scene.tool_settings - - if not tool_settings.use_snap: - return location - - if "VERTEX" not in tool_settings.snap_elements_base: - return location - - 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 - mouse_vec = Vector(mouse_coords) if mouse_coords is not None else None - - closest_point: Vector | None = None - closest_dist_sq = float("inf") - - if self._snap_cache is not None: - closest_point, closest_dist_sq = self._snap_from_cache( - location, mouse_vec, region, rv3d - ) - else: - closest_point, closest_dist_sq = self._snap_without_cache( - location, context, active_obj, mouse_vec, region, rv3d - ) - - max_dist_sq = SNAP_SCREEN_DISTANCE ** 2 if use_screen_distance else SNAP_WORLD_DISTANCE ** 2 - if closest_point and closest_dist_sq < max_dist_sq: - return closest_point - - return location - - def _snap_from_cache( - self, - location: Vector, - mouse_vec: Vector | None, - region: bpy.types.Region | None, - rv3d: bpy.types.RegionView3D | None, - ) -> tuple[Vector | None, float]: - """Find closest vertex using cached KD-tree.""" - if self._snap_cache is None: - return None, float("inf") - - cache = self._snap_cache - return SnapManager._find_closest_vertex( - cache.all_vertices, location, mouse_vec, region, rv3d, - None, float("inf"), cache.kd_tree - ) - - def _snap_without_cache( - self, - location: Vector, - context: bpy.types.Context, - active_obj: bpy.types.Object, - mouse_vec: Vector | None, - region: bpy.types.Region | None, - rv3d: bpy.types.RegionView3D | None, - ) -> tuple[Vector | None, float]: - """Find closest vertex without cache (fallback path).""" - 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 None, float("inf") - - nearby_objects = SnapManager._get_nearby_objects(mesh_objects, location) - if not nearby_objects: - return None, float("inf") - - closest_point: Vector | None = None - closest_dist_sq = float("inf") - depsgraph = context.evaluated_depsgraph_get() - - for obj in nearby_objects: - mesh_data = obj.data - if not hasattr(mesh_data, "vertices") or not mesh_data.vertices: - continue - - obj_eval = obj.evaluated_get(depsgraph) - mesh = obj_eval.to_mesh() - - try: - if not mesh or not mesh.vertices: - continue - - world_vertices = [obj_eval.matrix_world @ v.co for v in mesh.vertices] - closest_point, closest_dist_sq = SnapManager._find_closest_vertex( - world_vertices, location, mouse_vec, region, rv3d, - closest_point, closest_dist_sq - ) - finally: - obj_eval.to_mesh_clear() - - return closest_point, closest_dist_sq - - -_snap_manager = SnapManager() - - -def set_snap_point(point: tuple[float, float, float] | Vector | None) -> 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, - active_obj: bpy.types.Object, - mouse_coords: tuple[float, float] | None = None, -) -> Vector: - return _snap_manager.snap_to_mesh(location, context, active_obj, mouse_coords) - - -def build_snap_cache(context: bpy.types.Context, active_obj: bpy.types.Object) -> None: - _snap_manager.build_snap_cache(context, active_obj) - - -def clear_snap_cache() -> None: - _snap_manager.clear_snap_cache() - - -def get_billboard_rotation(context: bpy.types.Context) -> Matrix: - """Get rotation matrix that makes an object face the camera.""" - rv3d = context.region_data - if rv3d is None: - return Matrix.Identity(4) - return rv3d.view_matrix.to_3x3().transposed().to_4x4() - - -def get_camera_direction(context: bpy.types.Context, position: Vector) -> Vector | None: - """Get normalized direction from position towards camera.""" - rv3d = context.region_data - if rv3d is None: - return None - - if rv3d.is_perspective: - view_origin = rv3d.view_matrix.inverted().translation - return (view_origin - position).normalized() - return Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized() - - -def generate_circle_vertices( - center: tuple[float, float, float] | Vector, radius: float, segments: int, plane: str = "XY" -) -> list[tuple[float, float, float]]: - """Generate circle vertices in specified plane ('XY', 'XZ', or 'YZ').""" - 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_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 circle arc with cross-section thickness for visibility 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 = 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 = angle_min_rad + angle_range * (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 BIM_OT_gizmo_value_input(bpy.types.Operator): - """Enter a numeric value for a gizmo property. Click or Enter to confirm, ESC to cancel.""" - - bl_idname = "bim.gizmo_value_input" - bl_label = "Gizmo Value Input" - bl_options = {"REGISTER", "UNDO", "INTERNAL"} - - prop_name: bpy.props.StringProperty(name="Property Name", default="Value") - init_value: bpy.props.FloatProperty(name="Initial Value", default=0.0) - invert_delta: bpy.props.BoolProperty(name="Invert Delta", default=False) - - def invoke(self, context, event): - self._keyboard_input = NumericInputState.create_default() - self._move_set_cb = _gizmo_value_input_callback.get("move_set_cb") - self._active_gizmo = _gizmo_value_input_callback.get("active_gizmo") - self._gizmo_group = _gizmo_value_input_callback.get("gizmo_group") - self._hidden_gizmos: list[bpy.types.Gizmo] = [] - self._original_color: tuple[float, float, float] | None = None - - # Mouse movement context - self._start_location: Vector = _gizmo_value_input_callback.get("start_location", Vector()) - self._axis_direction: Vector = _gizmo_value_input_callback.get("axis_direction", Vector((0, 0, 1))) - self._active_obj: bpy.types.Object | None = _gizmo_value_input_callback.get("active_obj") - self._delta_scale: float = _gizmo_value_input_callback.get("delta_scale", 1.0) - self._mouse_delta: float = 0.0 - - # Snapping state - self._initial_snap_state: bool = context.scene.tool_settings.use_snap - self._snap_cache_built: bool = False - self._is_snapping: bool = False - - self._hide_other_gizmos() - self._set_highlight_color() - - context.window_manager.modal_handler_add(self) - self._update_header(context) - return {"RUNNING_MODAL"} - - def _set_highlight_color(self) -> None: - if not self._active_gizmo: - return - self._original_color = tuple(self._active_gizmo.color) - self._active_gizmo.color = self._active_gizmo.color_highlight - - def _restore_color(self) -> None: - if self._active_gizmo and self._original_color: - self._active_gizmo.color = self._original_color - - def _hide_other_gizmos(self) -> None: - if not self._gizmo_group or not self._active_gizmo: - return - - # Store hidden gizmos so update_property_gizmos respects it. - hidden_set: set[bpy.types.Gizmo] = set() - for gizmo in self._gizmo_group.gizmos: - if gizmo != self._active_gizmo: - gizmo.hide = True - hidden_set.add(gizmo) - self._hidden_gizmos.append(gizmo) - - # Store in module-level dict so gizmo group methods can check it - _gizmo_value_input_callback["hidden_gizmos"] = hidden_set - - def _restore_gizmo_visibility(self) -> None: - # Clear the hidden set first so refresh doesn't re-hide. - _gizmo_value_input_callback.pop("hidden_gizmos", None) - for gizmo in self._hidden_gizmos: - gizmo.hide = False - self._hidden_gizmos.clear() - - def modal(self, context, event): - kb = self._keyboard_input - - # Character input - if event.value == "PRESS" and event.ascii and event.ascii.lower() in NUMERIC_INPUT_CHARS: - kb.characters.append(event.ascii) - kb.is_active = True - kb.parse() - self._apply_value() - self._update_header(context) - return {"RUNNING_MODAL"} - - if event.type == "BACK_SPACE" and event.value == "PRESS": - if kb.characters: - kb.characters.pop() - kb.parse() - self._apply_value() - self._update_header(context) - return {"RUNNING_MODAL"} - - if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS": - if kb.is_valid: - self._apply_value() - self._cleanup(context) - return {"FINISHED"} - - if event.type == "LEFTMOUSE" and event.value == "PRESS": - if kb.characters: - if kb.is_valid: - self._apply_value() - self._cleanup(context) - return {"FINISHED"} - self._cleanup(context) - return {"CANCELLED"} - - if event.type == "ESC" and event.value == "PRESS": - if self._move_set_cb: - self._move_set_cb(self.init_value) - self._cleanup(context) - return {"CANCELLED"} - - if event.type == "RIGHTMOUSE" and event.value == "PRESS": - if self._move_set_cb: - self._move_set_cb(self.init_value) - self._cleanup(context) - return {"CANCELLED"} - - # Mouse movement - only when not typing - if event.type == "MOUSEMOVE" and not kb.characters: - self._handle_mouse_move(context, event) - return {"RUNNING_MODAL"} - - return {"RUNNING_MODAL"} - - def _handle_mouse_move(self, context, event) -> None: - region = context.region - rv3d = context.region_data - tool_settings = context.scene.tool_settings - if not region or not rv3d: - return - - # Ctrl toggles snapping - self._is_snapping = not self._initial_snap_state if event.ctrl else self._initial_snap_state - - # Build snap cache lazily on first Ctrl press - if self._is_snapping and not self._snap_cache_built and self._active_obj: - build_snap_cache(context, self._active_obj) - self._snap_cache_built = True - - 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) - - result = intersect_line_line( - view_origin, - view_origin + view_direction * RAY_CAST_DISTANCE, - self._start_location, - self._start_location + self._axis_direction * RAY_CAST_DISTANCE, - ) - current_3d = result[1] if result else self._start_location - - delta = (current_3d - self._start_location).dot(self._axis_direction) - - # Snapping - if self._is_snapping and self._active_obj: - # Temporarily set tool_settings for snap_to_mesh - original_snap = tool_settings.use_snap - tool_settings.use_snap = True - snapped_pos = snap_to_mesh(current_3d, context, self._active_obj, current_coord) - tool_settings.use_snap = original_snap - if snapped_pos != current_3d: - delta = (snapped_pos - self._start_location).dot(self._axis_direction) - set_snap_point(snapped_pos) - else: - clear_snap_point() - else: - clear_snap_point() - - # Precision mode with Shift - if event.shift: - delta *= PRECISION_MODE_MULTIPLIER - - if self.invert_delta: - delta = -delta - - delta *= self._delta_scale - self._mouse_delta = delta - - if self._move_set_cb: - self._move_set_cb(self.init_value + delta) - - self._update_header(context) - - def _apply_value(self) -> None: - if not self._move_set_cb or not self._keyboard_input.is_valid: - return - final_value = self._keyboard_input.calculate_final_value(self.init_value, self.invert_delta) - self._move_set_cb(final_value) - - def _update_header(self, context) -> None: - if not context.area: - return - kb = self._keyboard_input - - if kb.characters: - # Typing mode - show keyboard input - input_str = kb.get_input_string() - preview = kb.calculate_final_value(self.init_value, self.invert_delta) - validity = "" if kb.is_valid else " [invalid]" - header = f"{self.prop_name}: {preview:.3f}m | Input: {input_str}_{validity}" - header += " | Click/Enter: Confirm | ESC: Cancel" - else: - # Mouse mode - show current value from mouse delta - current_value = self.init_value + self._mouse_delta - header = f"{self.prop_name}: {current_value:.3f}m" - hints = [] - if self._is_snapping: - hints.append("Snapping: ON") - hints.extend(["Ctrl: Snap", "Shift: Precision", "Type: Enter Value"]) - header += " | " + " | ".join(hints) - header += " | Click/Enter: Confirm | ESC: Cancel" - - context.area.header_text_set(header) - - def _cleanup(self, context) -> None: - try: - if context.area: - context.area.header_text_set(None) - finally: - try: - self._restore_color() - finally: - try: - self._restore_gizmo_visibility() - # Trigger gizmo group refresh to recalculate positions based on new values - if self._gizmo_group and hasattr(self._gizmo_group, "refresh"): - self._gizmo_group.refresh(context) - # Force viewport redraw to update gizmo display - if context.area: - context.area.tag_redraw() - finally: - clear_snap_point() - clear_snap_cache() - _gizmo_value_input_callback.clear() - - -class GizmoMovable(bpy.types.Gizmo): - """Base class for draggable gizmos. Ctrl: snap, Shift: precision, Keyboard: direct input. - - Click without dragging enters a keyboard-only input mode for accessibility. - """ - - __slots__ = ( - "custom_shape", - "init_value", - "move_get_cb", - "move_set_cb", - "axis", - "local_axis", - "start_location", - "active_obj", - "initial_snap_state", - "invert_delta", - "delta_scale", - "prop_name", - "keyboard_input", - "gizmo_group", - "_snap_cache_built", - "_start_mouse_pos", - "_has_dragged", - ) - - # Threshold in pixels for considering mouse movement as a drag - DRAG_THRESHOLD = 5 - - 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 - self.keyboard_input = NumericInputState.create_default() - self._snap_cache_built = False - self._start_mouse_pos = (event.mouse_region_x, event.mouse_region_y) - self._has_dragged = False - if not hasattr(self, "prop_name") or self.prop_name is None: - self.prop_name = "Value" - # Build cache immediately only if snap is already enabled - if self.initial_snap_state and self.active_obj: - build_snap_cache(context, self.active_obj) - self._snap_cache_built = True - return {"RUNNING_MODAL"} - - def exit(self, context: bpy.types.Context, cancel: bool) -> None: - if context.area: - context.area.header_text_set(None) - if hasattr(self, "keyboard_input"): - self.keyboard_input.reset() - - # Check for click-without-drag: invoke keyboard input operator for accessibility - should_invoke_keyboard = ( - not cancel - and hasattr(self, "_has_dragged") - and not self._has_dragged - and self.move_set_cb is not None - ) - - if should_invoke_keyboard: - # Store callback and gizmo references in module-level dict - _gizmo_value_input_callback["move_set_cb"] = self.move_set_cb - _gizmo_value_input_callback["active_gizmo"] = self - _gizmo_value_input_callback["gizmo_group"] = getattr(self, "gizmo_group", None) - # Store context for mouse movement support - _gizmo_value_input_callback["start_location"] = self.start_location.copy() - _gizmo_value_input_callback["axis_direction"] = self.get_axis_direction() - _gizmo_value_input_callback["active_obj"] = self.active_obj - _gizmo_value_input_callback["delta_scale"] = getattr(self, "delta_scale", 1.0) - bpy.ops.bim.gizmo_value_input( - "INVOKE_DEFAULT", - prop_name=getattr(self, "prop_name", "Value"), - init_value=self.init_value, - invert_delta=getattr(self, "invert_delta", False), - ) - elif 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() - clear_snap_cache() - - def get_axis_direction(self) -> Vector: - """Get the world-space axis direction, transforming local_axis if set.""" - if hasattr(self, "local_axis") and self.active_obj: - obj_rotation = self.active_obj.matrix_world.to_3x3() - axis_direction: Vector = obj_rotation @ self.local_axis - axis_direction.normalize() - return axis_direction - 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 - - keyboard_result = self._handle_keyboard_input(context, event) - if keyboard_result is not None: - return keyboard_result - - if self.keyboard_input.is_active: - return {"RUNNING_MODAL"} - - if not region or not rv3d: - return {"RUNNING_MODAL"} - - tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state - - # Build snap cache lazily on first Ctrl press if not already built - if tool_settings.use_snap and not self._snap_cache_built and self.active_obj: - build_snap_cache(context, self.active_obj) - self._snap_cache_built = True - - current_coord = (event.mouse_region_x, event.mouse_region_y) - - # Detect if user has dragged beyond threshold - if not self._has_dragged and hasattr(self, "_start_mouse_pos"): - dx = current_coord[0] - self._start_mouse_pos[0] - dy = current_coord[1] - self._start_mouse_pos[1] - if (dx * dx + dy * dy) > (self.DRAG_THRESHOLD ** 2): - self._has_dragged = True - 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 * RAY_CAST_DISTANCE, - self.start_location, - 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, self.active_obj, current_coord) - 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 getattr(self, "invert_delta", False): - delta = -delta - - # Apply delta scale (e.g., 2.0 for symmetric thickness properties) - delta_scale = getattr(self, "delta_scale", 1.0) - delta *= delta_scale - - kb = self.keyboard_input - final_delta = kb.parsed_value if kb.parsed_value != 0.0 else delta - - if self.move_set_cb: - self.move_set_cb(self.init_value + final_delta) - - self._update_header(context, self.init_value + final_delta, tool_settings.use_snap, event.shift) - - return {"RUNNING_MODAL"} - - def _handle_keyboard_input( - self, context: bpy.types.Context, event: bpy.types.Event - ) -> set[str] | None: - """Handle keyboard numeric input.""" - kb = self.keyboard_input - - if event.value == "PRESS" and event.ascii and event.ascii.lower() in NUMERIC_INPUT_CHARS: - kb.characters.append(event.ascii) - kb.is_active = True - kb.parse() - self._apply_keyboard_value() - self._update_header_typing(context) - return {"RUNNING_MODAL"} - - if event.type == "BACK_SPACE" and event.value == "PRESS": - if kb.characters: - kb.characters.pop() - kb.parse() - self._apply_keyboard_value() - self._update_header_typing(context) - elif kb.is_active: - kb.reset() - if self.move_set_cb: - self.move_set_cb(self.init_value) - return {"RUNNING_MODAL"} - - if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS": - if kb.is_active and kb.is_valid: - final_value = kb.calculate_final_value(self.init_value, getattr(self, "invert_delta", False)) - kb.characters.clear() - kb.is_active = False - self._update_header(context, final_value, False, False) - return {"RUNNING_MODAL"} - - if event.type == "ESC" and event.value == "PRESS" and kb.is_active: - kb.reset() - if self.move_set_cb: - self.move_set_cb(self.init_value) - return {"RUNNING_MODAL"} - - return None - - def _apply_keyboard_value(self) -> None: - kb = self.keyboard_input - if self.move_set_cb and kb.is_valid: - final_value = kb.calculate_final_value(self.init_value, getattr(self, "invert_delta", False)) - self.move_set_cb(final_value) - - def _update_header_typing(self, context: bpy.types.Context) -> None: - if not context.area: - return - kb = self.keyboard_input - input_str = kb.get_input_string() - preview = kb.calculate_final_value(self.init_value, getattr(self, "invert_delta", False)) - - validity = "" if kb.is_valid else " [invalid]" - prop_display = getattr(self, "prop_name", "Value") - - header = f"{prop_display}: {preview:.3f}m | Input: {input_str}_{validity}" - header += " | Enter: Confirm | Backspace: Delete | ESC: Cancel" - context.area.header_text_set(header) - - def _update_header(self, context: bpy.types.Context, value: float, is_snapping: bool, is_precision: bool) -> None: - if not context.area: - return - prop_display = getattr(self, "prop_name", "Value") - hints = [] - if is_snapping: - hints.append("Snapping: ON") - if is_precision: - hints.append("Precision (0.1x)") - hints.extend(["Ctrl: Snap", "Shift: Precision", "Type: Enter Value"]) - - header_text = f"{prop_display}: {value:.3f}m | " + " | ".join(hints) - context.area.header_text_set(header_text) - - -class GizmoLock(bpy.types.Gizmo): - """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.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.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) -> object: - """Get the appropriate custom shape based on lock state.""" - 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 (ValueError, KeyError, AttributeError): - 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): - """Arc gizmo for door swing visualization.""" - - bl_idname = "VIEW3D_GT_arc" - - __slots__ = ( - "custom_shape_left", - "custom_shape_right", - "prop_path", - ) - - def setup(self) -> None: - """Create arc shapes for 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) -> object: - """Get arc shape based on door swing direction.""" - 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 (ValueError, KeyError, AttributeError): - 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): - """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): - """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): - """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 GizmoPlus(bpy.types.Gizmo): - """Plus icon gizmo for incrementing values.""" - - bl_idname = "VIEW3D_GT_plus" - - __slots__ = ("custom_shape",) - - 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), - (-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): - """Minus icon gizmo for decrementing values.""" - - bl_idname = "VIEW3D_GT_minus" - - __slots__ = ("custom_shape",) - - 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 covering ~300 degrees.""" - triangles = [] - radius = 0.375 - line_width = 0.10 - half_width = line_width / 2 - - segments = 20 - start_angle = math.radians(30) - end_angle = math.radians(330) - angle_range = end_angle - start_angle - - 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)) - - 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 - - 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), - ] - ) - - 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), - ] - ) - - arrow_size = 0.30 - end_x, end_y = arc_points[-1] - 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 - - tip_x = end_x + tangent_x * arrow_size * 0.5 - tip_y = end_y + tangent_y * arrow_size * 0.5 - - perp_x = -tangent_y * arrow_size - perp_y = tangent_x * arrow_size - - 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), - ] - ) - - 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): - """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.""" - - 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], ...]: - 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 GizmoArrow2D(GizmoMovable): - """Flat 2D arrow that rotates around its axis to face the camera.""" - - bl_idname = "BIM_GT_gizmo_arrow_2d" - bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) - - ARROW_2D_SHAFT_LENGTH = 0.5 - ARROW_2D_HEAD_LENGTH = 0.5 - ARROW_2D_WIDTH = 0.25 - ARROW_2D_HEAD_WIDTH = 0.75 - - def _get_arrow_2d_triangles(self) -> tuple[tuple[float, float, float], ...]: - """Generate flat arrow geometry in XY plane, pointing along +X.""" - shaft = self.ARROW_2D_SHAFT_LENGTH - head = self.ARROW_2D_HEAD_LENGTH - w = self.ARROW_2D_WIDTH / 2 - hw = self.ARROW_2D_HEAD_WIDTH / 2 - - return ( - # Shaft - (0, -w, 0), (shaft, -w, 0), (0, w, 0), - (0, w, 0), (shaft, -w, 0), (shaft, w, 0), - # Head - (shaft, -hw, 0), (shaft + head, 0, 0), (shaft, hw, 0), - ) - - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self._get_arrow_2d_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) - - def draw_prepare(self, context: bpy.types.Context) -> None: - """Rotate around arrow axis to face camera.""" - position = self.matrix_basis.translation - to_camera = get_camera_direction(context, position) - if to_camera is None: - return - - axis_world = Vector(self.matrix_basis.col[0][:3]).normalized() - to_camera_projected = to_camera - axis_world * to_camera.dot(axis_world) - - if to_camera_projected.length_squared < 1e-6: - return - - to_camera_projected.normalize() - - local_z_world = Vector(self.matrix_basis.col[2][:3]).normalized() - local_z_projected = local_z_world - axis_world * local_z_world.dot(axis_world) - - if local_z_projected.length_squared < 1e-6: - return - - local_z_projected.normalize() - - cross = local_z_projected.cross(to_camera_projected) - dot = local_z_projected.dot(to_camera_projected) - sign = 1.0 if cross.dot(axis_world) >= 0 else -1.0 - angle = sign * math.acos(max(-1.0, min(1.0, dot))) - - axis_rot = Matrix.Rotation(angle, 4, "X") - current_scale = self.matrix_offset.to_scale() if self.matrix_offset else Vector((1, 1, 1)) - self.matrix_offset = axis_rot @ Matrix.Scale(current_scale[0], 4) - - -class GizmoCone(GizmoMovable): - """Cone gizmo for directional value editing.""" - - bl_idname = "BIM_GT_gizmo_cone" - bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) - - def _get_cone_triangles(self) -> tuple[tuple[float, float, float], ...]: - 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) - - -class BaseParametricGizmoGroup: - """Base mixin for parametric element gizmo groups (doors, windows, etc.).""" - - COLOR_RED = (1.0, 0.2, 0.2) - COLOR_GREEN = (0.1, 0.8, 0.1) - COLOR_BLUE = (0.3, 0.3, 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 = "" - cycle_type_operator: str = "" - - @classmethod - def get_arrow_color_from_axis(cls, axis: tuple[int, int, int]) -> tuple[float, float, float]: - 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: - 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: - raise NotImplementedError("Subclass must implement is_element_type()") - - @classmethod - def poll(cls, context) -> bool: - 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: - raise NotImplementedError("Subclass must implement get_props()") - - def get_gizmo_prefs(self) -> Any: - raise NotImplementedError("Subclass must implement get_gizmo_prefs()") - - def get_prop_min_value(self, attr_name: str) -> float: - return 0.0 - - def should_hide_gizmo(self, attr_name: str, props) -> bool: - return not props.is_editing - - def get_element_height(self, props) -> float: - return getattr(props, "overall_height", getattr(props, "height", 1.0)) - - def setup_property_gizmos(self, context: bpy.types.Context) -> None: - 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_arrow_2d") - - 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.delta_scale = prop_config.delta_scale - - gizmo.prop_name = attr_name.replace("_", " ").title() - gizmo.gizmo_group = self - - 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: - 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) - - if self.cycle_type_operator: - self.cycle_gizmo = self.gizmos.new("VIEW3D_GT_cycle") - self.cycle_gizmo.use_draw_scale = False - self.cycle_gizmo.color = default_color - self.cycle_gizmo.color_highlight = highlight_color - self.cycle_gizmo.alpha = 0.8 - self.cycle_gizmo.target_set_operator(self.cycle_type_operator) - - def update_property_gizmos(self, mw, props) -> None: - gizmo_prefs = self.get_gizmo_prefs() - # Check if keyboard input modal is hiding gizmos - hidden_by_modal = _gizmo_value_input_callback.get("hidden_gizmos", set()) - - 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 - - # Respect gizmos hidden by keyboard input modal - if gizmo in hidden_by_modal: - gizmo.hide = True - continue - - if not getattr(gizmo_prefs, attr_name, True): - gizmo.hide = True - continue - - if self.should_hide_gizmo(attr_name, props): - gizmo.hide = True - continue - - 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, context: bpy.types.Context, mw, props) -> None: - icon_z = self.get_element_height(props) + 0.5 - billboard_rot = get_billboard_rotation(context) - # Check if keyboard input modal is hiding gizmos - hidden_by_modal = _gizmo_value_input_callback.get("hidden_gizmos", set()) - - local_transform = ( - Matrix.Translation(Vector((0, 0.0, icon_z))) - @ billboard_rot - @ Matrix.Scale(0.5, 4) - ) - icon_matrix_base = mw @ local_transform - - if props.is_editing: - self.pen_gizmo.hide = True - self.validate_gizmo.hide = self.validate_gizmo in hidden_by_modal - self.validate_gizmo.matrix_basis = icon_matrix_base - self.cancel_gizmo.hide = self.cancel_gizmo in hidden_by_modal - cancel_local = Matrix.Translation(Vector((0.5, 0.0, 0.0))) @ local_transform - self.cancel_gizmo.matrix_basis = mw @ cancel_local - if self.cycle_type_operator: - self.cycle_gizmo.hide = self.cycle_gizmo in hidden_by_modal - cycle_transform = ( - Matrix.Translation(Vector((0, 0.0, icon_z))) - @ billboard_rot - @ Matrix.Scale(0.30, 4) - ) - cycle_local = Matrix.Translation(Vector((0.87, 0.0, 0.0))) @ cycle_transform - self.cycle_gizmo.matrix_basis = mw @ cycle_local - else: - self.pen_gizmo.hide = self.pen_gizmo in hidden_by_modal - self.pen_gizmo.matrix_basis = icon_matrix_base - self.validate_gizmo.hide = True - self.cancel_gizmo.hide = True - if self.cycle_type_operator: - self.cycle_gizmo.hide = True - - def draw_prepare(self, context: bpy.types.Context) -> None: - """Called before drawing - updates gizmos to face camera.""" - obj = context.active_object - if not obj: - return - props = self.get_props(obj) - mw = obj.matrix_world - self.update_editing_gizmos(context, mw, props) - - for prop_config in self.gizmo_props: - gizmo = getattr(self, f"gizmo_{prop_config.attr_name}", None) - if gizmo and hasattr(gizmo, "draw_prepare") and not gizmo.hide: - gizmo.draw_prepare(context) diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 187020fc77..8153671f09 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -111,9 +111,22 @@ classes = ( ui.BIM_PT_text, ui.BIM_UL_drawinglist, ui.BIM_UL_sheets, + # Core gizmos (shared across modules) + gizmos.BIM_OT_gizmo_value_input, + gizmos.GizmoArrow, + gizmos.GizmoArrow2D, + gizmos.GizmoCone, + gizmos.GizmoDimension, + gizmos.GizmoLock, + gizmos.GizmoArc, + gizmos.GizmoPen, + gizmos.GizmoValidate, + gizmos.GizmoCancel, + gizmos.GizmoPlus, + gizmos.GizmoMinus, + gizmos.GizmoCycle, + # Drawing-specific gizmos gizmos.UglyDotGizmo, - gizmos.DotGizmo, - gizmos.DimensionLabelGizmo, gizmos.ExtrusionGuidesGizmo, gizmos.ExtrusionWidget, workspace.LaunchAnnotationTypeManager, diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 34458c0920..17b0b33159 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -1,4 +1,5 @@ # Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult # Copyright (C) 2020, 2021 Maxim Vasilyev # # This file is part of Bonsai. @@ -16,48 +17,1348 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -import bpy +""" +Gizmo infrastructure for parametric BIM element editing. + +This module provides a framework for interactive 3D gizmos that allow users to +manipulate parametric properties of BIM elements (doors, windows, stairs) directly +in the viewport. + +Architecture Overview +===================== + +The gizmo system follows a configuration-driven approach where element-specific +gizmo groups (e.g., GizmoDoorEdition) inherit from BaseParametricGizmoGroup and +declare their gizmos via configuration dataclasses: + + class GizmoDoorEdition(bpy.types.GizmoGroup, BaseParametricGizmoGroup): + dimension_gizmo_props = [ + DimensionGizmoConfig("overall_width", axis=(1, 0, 0)), + DimensionGizmoConfig("overall_height", axis=(0, 0, 1)), + ] + +Key Components +============== + +Configuration Classes: + - DimensionGizmoConfig: Configures dimension line gizmos with text display + +Base Gizmo Classes: + - GizmoMovable: Base for draggable gizmos with keyboard input support + - GizmoDimension: Dimension line gizmo with arrows and text labels + - GizmoArrow2D: 2D arrow gizmo for property manipulation + +Mixin Classes: + - BaseParametricGizmoGroup: Provides common setup/update methods for gizmo groups + +Utility Classes: + - GPUStateScope: Context manager for GPU state save/restore + - NumericInputState: Tracks keyboard numeric input during modal operations + +Global State: + - _gizmo_modal_context: Module-level dataclass instance for modal operator communication + (workaround for Blender's ID property limitations) + +Data Flow +========= + +1. User selects a parametric element (door, window, stair) +2. GizmoGroup.poll() checks if gizmos should be shown +3. GizmoGroup.setup() creates gizmos based on configs +4. GizmoGroup.refresh() updates gizmo positions from element properties +5. User interacts with gizmo -> invoke() -> modal() -> exit() +6. Property changes are written back via move_set_cb callbacks +7. Element mesh is regenerated via operators (e.g., bim.finish_editing_door) + +Snapping System +=============== + +The module includes a mesh vertex snapping system: + - build_snap_cache(): Builds KD-tree from nearby object vertices + - snap_to_mesh(): Snaps 3D position to nearest vertex within threshold + - Uses screen-space distance filtering for accurate snapping + +View-Dependent Positioning +========================== + +Dimension gizmos automatically reposition based on camera view direction to avoid +overlapping with geometry. The get_local_view_direction() helper determines if the +camera is viewing from the positive or negative side of each axis. +""" + +__all__ = [ + "DimensionGizmoConfig", + "NumericInputState", + "GPUStateScope", + "set_snap_point", + "clear_snap_point", + "snap_to_mesh", + "build_snap_cache", + "clear_snap_cache", + "get_billboard_rotation", + "get_camera_direction", + "generate_circle_vertices", + "create_circle_arc", + "BIM_OT_gizmo_value_input", + "GizmoMovable", + "GizmoLock", + "GizmoArc", + "GizmoPen", + "GizmoValidate", + "GizmoCancel", + "GizmoPlus", + "GizmoMinus", + "GizmoCycle", + "GizmoArrow", + "GizmoArrow2D", + "GizmoCone", + "GizmoDimension", + "DimensionRenderer", + "BaseParametricGizmoGroup", + "UglyDotGizmo", + "ExtrusionGuidesGizmo", + "ExtrusionWidget", +] + +from typing import Any, Callable, Iterator + import blf +import bpy import gpu -import bonsai.tool as tool +import math +import numpy as np from bpy import types -from mathutils import Vector -from mathutils import geometry +from dataclasses import dataclass +from mathutils import Vector, Matrix +from mathutils.kdtree import KDTree +from mathutils.geometry import intersect_line_line +from bpy_extras.view3d_utils import region_2d_to_vector_3d, region_2d_to_origin_3d, location_3d_to_region_2d from bpy_extras import view3d_utils -from bonsai.bim.module.drawing.shaders import DotsGizmoShader, ExtrusionGuidesShader +from gpu_extras.batch import batch_for_shader +import bonsai.tool as tool +from bonsai.tool.unit import parse_distance_string +from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader from ifcopenshell.util.unit import si_conversions -"""Gizmos under the hood +SNAP_POINT_SIZE = 10.0 +SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0) +SNAP_MAX_RADIUS = 50.0 +SNAP_SCREEN_DISTANCE = 15 +SNAP_WORLD_DISTANCE = 0.2 +# Query multiple 3D-nearest candidates because screen-nearest may differ from 3D-nearest +SNAP_KD_CANDIDATES = 64 -## Transforms: +ARROW_SHAFT_LENGTH = 0.8 +ARROW_HEAD_LENGTH = 0.2 +ARROW_WIDTH = 0.015 +ARROW_HEAD_WIDTH_MULTIPLIER = 10 +ARROW_CIRCLE_SEGMENTS = 8 -source/blender/windowmanager/gizmo/WM_gizmo_types.h -matrix_basis -- "Transformation of this gizmo." = placement in scene -matrix_offset -- "Custom offset from origin." = local transforms according to state/value -matrix_space -- "The space this gizmo is being modified in." used by some gizmos for undefined purposes -matrix_world -- final matrix, scaled according to viewport zoom and custom scale_basis +CONE_LENGTH = 1.0 +CONE_RADIUS = 0.35 +CONE_SEGMENTS = 16 -source/blender/windowmanager/gizmo/intern/wm_gizmo.c:WM_gizmo_calc_matrix_final_params -final = space @ (autoscale * (basis @ offset)) -final = space @ (basis @ offset) -- if gizmo.use_draw_scale == False -final = space @ ((autoscale * basis) @ offset) -- if gizmo.use_draw_offset_scale +ARC_SEGMENTS = 24 +ARC_LINE_WIDTH = 0.015 -source/blender/windowmanager/gizmo/intern/wm_gizmo.c:wm_gizmo_calculate_scale -autoscale = gizmo.scale_basis * magic(preferences, matrix_space, matrix_basis, context.region_data) -magic -- making 1.0 to match preferences.view.gizmo_size pixels (75 by default) +PRECISION_MODE_MULTIPLIER = 0.1 + +RAY_CAST_DISTANCE = 1000 +DEFAULT_POINT_SIZE = 1.0 + +# Characters allowed for keyboard numeric input (supports units and formulas) +_DIGITS = set("0123456789") +_OPERATORS = {".", "-", "+", "*", "/"} +_METRIC_UNITS = {"m", "c", "d"} # m, cm, dm, mm +_IMPERIAL_UNITS = {"f", "t", "i", "n", "'", '"'} # ft, in, ', " +_SPECIAL = {"=", " "} # Formula prefix, spaces + +NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL -## Selection +@dataclass +class GizmoModalContext: + """Typed context for modal gizmo operations. -select_id -- apparently, id of a selectable part -test_select -- expected to return id of selection, doesn't seem to work -draw_select -- fake-draw of selection geometry for gpu-side cursor tracking + This replaces the untyped dict pattern for passing state between gizmos + and the BIM_OT_gizmo_value_input modal operator. Blender ID properties + don't support function callbacks, so we use this module-level instance. -""" + Attributes: + move_set_cb: Callback to set the property value + active_gizmo: The gizmo currently being manipulated + gizmo_group: The gizmo group containing the active gizmo + start_location: World-space position where interaction started + axis_direction: Direction vector for the gizmo axis + active_obj: The Blender object being edited + delta_scale: Multiplier for delta values (e.g., 2.0 for symmetric properties) + click_offset: Offset from click position to gizmo tip + hidden_gizmos: Set of gizmos hidden during modal operation + """ + + move_set_cb: Callable[[float], None] | None = None + active_gizmo: bpy.types.Gizmo | None = None + gizmo_group: bpy.types.GizmoGroup | None = None + start_location: Vector | None = None + axis_direction: Vector | None = None + active_obj: bpy.types.Object | None = None + delta_scale: float = 1.0 + click_offset: float = 0.0 + hidden_gizmos: set[bpy.types.Gizmo] | None = None + + def clear(self) -> None: + """Reset all fields to default values.""" + self.move_set_cb = None + self.active_gizmo = None + self.gizmo_group = None + self.start_location = None + self.axis_direction = None + self.active_obj = None + self.delta_scale = 1.0 + self.click_offset = 0.0 + self.hidden_gizmos = None -# some geometries for Gizmo.custom_shape shaders +# Module-level instance for modal gizmo context +_gizmo_modal_context = GizmoModalContext() + + +class GPUStateScope: + """Context manager for saving and restoring GPU state. + + Automatically saves GPU state on entry and restores it on exit, + ensuring proper cleanup even if an exception occurs. + + Usage: + with GPUStateScope(depth_test='NONE', blend='ALPHA'): + ... + + with GPUStateScope(depth_test='NONE', blend='ALPHA', ortho_2d=(width, height)): + # 2D screen-space drawing + ... + """ + + __slots__ = ( + "_saved_depth_test", + "_saved_blend", + "_saved_projection", + "_saved_modelview", + "_depth_test", + "_blend", + "_ortho_2d", + ) + + def __init__( + self, + depth_test: str | None = None, + blend: str | None = None, + ortho_2d: tuple[float, float] | None = None, + ): + """Initialize with optional state overrides. + + Args: + depth_test: Depth test mode ('NONE', 'LESS', 'LESS_EQUAL', etc.) or None to keep current + blend: Blend mode ('NONE', 'ALPHA', 'ALPHA_PREMULT', etc.) or None to keep current + ortho_2d: If provided, set up 2D orthographic projection with (width, height) + """ + self._depth_test = depth_test + self._blend = blend + self._ortho_2d = ortho_2d + self._saved_depth_test: str = "" + self._saved_blend: str = "" + self._saved_projection: Matrix | None = None + self._saved_modelview: Matrix | None = None + + def __enter__(self) -> "GPUStateScope": + self._saved_depth_test = gpu.state.depth_test_get() + self._saved_blend = gpu.state.blend_get() + + if self._depth_test is not None: + gpu.state.depth_test_set(self._depth_test) + if self._blend is not None: + gpu.state.blend_set(self._blend) + + if self._ortho_2d is not None: + self._saved_projection = gpu.matrix.get_projection_matrix() + self._saved_modelview = gpu.matrix.get_model_view_matrix() + + width, height = self._ortho_2d + ortho = Matrix.Identity(4) + ortho[0][0] = 2.0 / width + ortho[0][3] = -1.0 + ortho[1][1] = 2.0 / height + ortho[1][3] = -1.0 + ortho[2][2] = -1.0 + + gpu.matrix.load_matrix(Matrix.Identity(4)) + gpu.matrix.load_projection_matrix(ortho) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + if self._saved_projection is not None: + gpu.matrix.load_projection_matrix(self._saved_projection) + if self._saved_modelview is not None: + gpu.matrix.load_matrix(self._saved_modelview) + + gpu.state.depth_test_set(self._saved_depth_test) + gpu.state.blend_set(self._saved_blend) + return None + + +class DimensionTextRenderer: + """Handles text rendering for dimension gizmos. + + Extracted from GizmoDimension to follow Single Responsibility Principle. + This class manages all text drawing operations including value text, + property tooltips, and text backgrounds. + + Usage: + renderer = DimensionTextRenderer.get_instance() + renderer.draw_value_text(context, screen_pos, perpendicular, value, color) + renderer.draw_property_tooltip(context, screen_pos, prop_name, color) + """ + + _instance: "DimensionTextRenderer | None" = None + _tri_shader = None + + # Text rendering parameters + VALUE_FONT_SIZE = 11 + TOOLTIP_FONT_SIZE = 10 + TEXT_PADDING = 3 + TOOLTIP_OFFSET = 15 + BACKGROUND_ALPHA = 0.7 + + @classmethod + def get_instance(cls) -> "DimensionTextRenderer": + """Get singleton instance of the text renderer.""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @classmethod + def _get_tri_shader(cls): + """Get cached UNIFORM_COLOR shader for triangles.""" + if cls._tri_shader is None: + cls._tri_shader = gpu.shader.from_builtin("UNIFORM_COLOR") + return cls._tri_shader + + def draw_value_text( + self, + context: bpy.types.Context, + screen_pos: tuple[float, float], + perpendicular: Vector, + value: float, + color: tuple[float, float, float], + offset_sign: int = 1, + alignment: str = "center", + ) -> None: + """Draw formatted dimension value text at the given screen position. + + Args: + context: Blender context + screen_pos: Screen-space position (x, y) + perpendicular: Perpendicular direction vector for offset + value: Dimension value to format and display + color: Text color (r, g, b) + offset_sign: 1 for above/right, -1 for below/left + alignment: "center" or "start" + """ + text = tool.Unit.format_distance(value) + + font_id = 0 + font_size = tool.Blender.scale_font_size(self.VALUE_FONT_SIZE) + blf.size(font_id, font_size) + blf.enable(font_id, blf.SHADOW) + blf.shadow(font_id, 6, 0, 0, 0, 1) + + text_width, text_height = blf.dimensions(font_id, text) + offset_distance = (text_height + 4) * offset_sign + + if alignment == "start": + text_x = screen_pos[0] + perpendicular[0] * offset_distance + text_y = screen_pos[1] - text_height / 2 + perpendicular[1] * offset_distance + else: + text_x = screen_pos[0] - text_width / 2 + perpendicular[0] * offset_distance + text_y = screen_pos[1] - text_height / 2 + perpendicular[1] * offset_distance + + self._draw_text_background(context, text_x, text_y, text_width, text_height) + + blf.color(font_id, *color, 1.0) + blf.position(font_id, text_x, text_y, 0) + blf.draw(font_id, text) + blf.disable(font_id, blf.SHADOW) + + def draw_property_tooltip( + self, + context: bpy.types.Context, + screen_pos: tuple[float, float], + prop_name: str, + color: tuple[float, float, float], + ) -> None: + """Draw a tooltip showing the property name near the given screen position. + + Args: + context: Blender context + screen_pos: Screen-space position (x, y) + prop_name: Property name to display (will be converted to Title Case) + color: Text color (r, g, b) + """ + prop_display = prop_name.replace("_", " ").title() + + font_id = 0 + font_size = tool.Blender.scale_font_size(self.TOOLTIP_FONT_SIZE) + blf.size(font_id, font_size) + blf.enable(font_id, blf.SHADOW) + blf.shadow(font_id, 6, 0, 0, 0, 1) + + text_width, text_height = blf.dimensions(font_id, prop_display) + + tooltip_x = screen_pos[0] + self.TOOLTIP_OFFSET + tooltip_y = screen_pos[1] + self.TOOLTIP_OFFSET + + self._draw_text_background(context, tooltip_x, tooltip_y, text_width, text_height) + + blf.color(font_id, *color, 1.0) + blf.position(font_id, tooltip_x, tooltip_y, 0) + blf.draw(font_id, prop_display) + blf.disable(font_id, blf.SHADOW) + + def _draw_text_background( + self, + context: bpy.types.Context, + x: float, + y: float, + width: float, + height: float, + ) -> None: + """Draw a semi-transparent background behind text.""" + padding = self.TEXT_PADDING + theme = context.preferences.themes.items()[0][1] + bg_color = (*theme.user_interface.wcol_menu_back.inner[:3], self.BACKGROUND_ALPHA) + + vertices = [ + (x - padding, y - padding), + (x + width + padding, y - padding), + (x + width + padding, y + height + padding), + (x - padding, y + height + padding), + ] + indices = [(0, 1, 2), (0, 2, 3)] + + shader = self._get_tri_shader() + shader.bind() + batch = batch_for_shader(shader, "TRIS", {"pos": vertices}, indices=indices) + shader.uniform_float("color", bg_color) + batch.draw(shader) + + +class DimensionRenderer: + """Handles rendering of dimension line graphics. + + Extracted from GizmoDimension to follow Single Responsibility Principle. + This class manages all dimension drawing operations including lines, + arrows, and extension lines in screen space. + + Usage: + renderer = DimensionRenderer.get_instance() + renderer.draw(context, start_world, end_world, ...) + """ + + _instance: "DimensionRenderer | None" = None + _line_shader = None + _tri_shader = None + + # Visual parameters (in pixels) + ARROW_SIZE = 10 + EXTENSION_LENGTH = 4 + LINE_WIDTH = 2.0 + MIN_PIXELS_FOR_DETAILS = 35 + + @classmethod + def get_instance(cls) -> "DimensionRenderer": + """Get singleton instance of the dimension renderer.""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @classmethod + def _get_line_shader(cls): + """Get cached POLYLINE_UNIFORM_COLOR shader.""" + if cls._line_shader is None: + cls._line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + return cls._line_shader + + @classmethod + def _get_tri_shader(cls): + """Get cached UNIFORM_COLOR shader for triangles.""" + if cls._tri_shader is None: + cls._tri_shader = gpu.shader.from_builtin("UNIFORM_COLOR") + return cls._tri_shader + + def draw( + self, + context: bpy.types.Context, + start_world: Vector, + end_world: Vector, + axis_world: Vector, + dimension_length: float, + color: tuple[float, float, float], + alpha: float, + is_highlight: bool, + highlight_color: tuple[float, float, float], + highlight_alpha: float, + show_start_arrow: bool = False, + show_end_arrow: bool = True, + show_extension_lines: bool = True, + text_offset_sign: int = 1, + text_alignment: str = "center", + prop_name: str | None = None, + ) -> None: + """Draw complete dimension graphics in screen space. + + Args: + context: Blender context + start_world: World-space start position + end_world: World-space end position + axis_world: Normalized axis direction in world space + dimension_length: Length of the dimension (for text display) + color: Base color (r, g, b) + alpha: Base alpha + is_highlight: Whether gizmo is highlighted/hovered + highlight_color: Highlight color (r, g, b) + highlight_alpha: Highlight alpha + show_start_arrow: Whether to show arrow at start + show_end_arrow: Whether to show arrow at end + show_extension_lines: Whether to show extension lines + text_offset_sign: 1 for above/right, -1 for below/left + text_alignment: "center" or "start" + prop_name: Property name for tooltip (shown when highlighted) + """ + if dimension_length < 0: + return + + region = context.region + rv3d = context.region_data + if not region or not rv3d: + return + + start_screen = location_3d_to_region_2d(region, rv3d, start_world) + end_screen = location_3d_to_region_2d(region, rv3d, end_world) + + if not start_screen or not end_screen: + return + + direction = Vector((end_screen[0] - start_screen[0], end_screen[1] - start_screen[1])) + length_screen = direction.length + + actual_value_is_zero = dimension_length <= 0.001 + + # When screen length is zero due to viewing angle (not actual value), skip drawing + if length_screen < 1 and not actual_value_is_zero: + return + + # When actual value is zero, determine direction from 3D axis projection + if length_screen < 1 and actual_value_is_zero: + test_world = start_world + axis_world * 0.1 + test_screen = location_3d_to_region_2d(region, rv3d, test_world) + if test_screen: + direction = Vector((test_screen[0] - start_screen[0], test_screen[1] - start_screen[1])) + if direction.length > 0.001: + direction.normalize() + else: + direction = Vector((1, 0)) + else: + direction = Vector((1, 0)) + else: + direction.normalize() + + perpendicular = Vector((-direction[1], direction[0])) + + vertices = [] + indices = [] + + line_start = (start_screen[0], start_screen[1]) + line_end = (end_screen[0], end_screen[1]) + + if show_start_arrow: + line_start = ( + start_screen[0] + direction[0] * self.ARROW_SIZE, + start_screen[1] + direction[1] * self.ARROW_SIZE, + ) + if show_end_arrow: + line_end = ( + end_screen[0] - direction[0] * self.ARROW_SIZE, + end_screen[1] - direction[1] * self.ARROW_SIZE, + ) + + vertices.append(line_start) + vertices.append(line_end) + indices.append((0, 1)) + + arrow_triangles = [] + + if show_start_arrow: + arrow_triangles.extend( + self._build_arrow_triangle(start_screen, direction, perpendicular, pointing_backward=False) + ) + + if show_end_arrow: + arrow_triangles.extend( + self._build_arrow_triangle(end_screen, direction, perpendicular, pointing_backward=True) + ) + + if show_extension_lines and length_screen >= self.MIN_PIXELS_FOR_DETAILS: + idx = len(vertices) + ext_start_top, ext_start_bottom = self._build_extension_line_vertices(start_screen, perpendicular) + vertices.append(ext_start_top) + vertices.append(ext_start_bottom) + indices.append((idx, idx + 1)) + + idx = len(vertices) + ext_end_top, ext_end_bottom = self._build_extension_line_vertices(end_screen, perpendicular) + vertices.append(ext_end_top) + vertices.append(ext_end_bottom) + indices.append((idx, idx + 1)) + + if is_highlight: + draw_color = (*highlight_color, highlight_alpha) + else: + draw_color = (*color, alpha) + + with GPUStateScope(depth_test="NONE", blend="ALPHA", ortho_2d=(region.width, region.height)): + shader = self._get_line_shader() + shader.bind() + shader.uniform_float("viewportSize", (region.width, region.height)) + shader.uniform_float("lineWidth", self.LINE_WIDTH) + shader.uniform_float("color", draw_color) + + line_batch = batch_for_shader(shader, "LINES", {"pos": vertices}, indices=indices) + line_batch.draw(shader) + + if arrow_triangles: + tri_shader = self._get_tri_shader() + tri_shader.bind() + tri_shader.uniform_float("color", draw_color) + tri_batch = batch_for_shader(tri_shader, "TRIS", {"pos": arrow_triangles}) + tri_batch.draw(tri_shader) + + if length_screen >= self.MIN_PIXELS_FOR_DETAILS: + center_screen = ( + (start_screen[0] + end_screen[0]) / 2, + (start_screen[1] + end_screen[1]) / 2, + ) + text_color = highlight_color if is_highlight else color + DimensionTextRenderer.get_instance().draw_value_text( + context, center_screen, perpendicular, dimension_length, text_color, text_offset_sign, text_alignment + ) + + if is_highlight and prop_name: + tooltip_color = highlight_color + DimensionTextRenderer.get_instance().draw_property_tooltip( + context, (end_screen[0], end_screen[1]), prop_name, tooltip_color + ) + + def _build_arrow_triangle( + self, position: Vector, direction: Vector, perpendicular: Vector, pointing_backward: bool + ) -> list[tuple[float, float]]: + """Build triangle vertices for an arrow head.""" + sign = -1 if pointing_backward else 1 + arrow_tip = (position[0], position[1]) + arrow_back_left = ( + position[0] + sign * direction[0] * self.ARROW_SIZE + perpendicular[0] * self.ARROW_SIZE * 0.5, + position[1] + sign * direction[1] * self.ARROW_SIZE + perpendicular[1] * self.ARROW_SIZE * 0.5, + ) + arrow_back_right = ( + position[0] + sign * direction[0] * self.ARROW_SIZE - perpendicular[0] * self.ARROW_SIZE * 0.5, + position[1] + sign * direction[1] * self.ARROW_SIZE - perpendicular[1] * self.ARROW_SIZE * 0.5, + ) + return [arrow_tip, arrow_back_left, arrow_back_right] + + def _build_extension_line_vertices( + self, position: Vector, perpendicular: Vector + ) -> tuple[tuple[float, float], tuple[float, float]]: + """Build extension line endpoints perpendicular to the dimension.""" + top = ( + position[0] + perpendicular[0] * self.EXTENSION_LENGTH, + position[1] + perpendicular[1] * self.EXTENSION_LENGTH, + ) + bottom = ( + position[0] - perpendicular[0] * self.EXTENSION_LENGTH, + position[1] - perpendicular[1] * self.EXTENSION_LENGTH, + ) + return (top, bottom) + + +@dataclass +class SnapCache: + """Unified snap cache with combined KD-tree for vertex snapping.""" + + # Combined KD-tree with all world vertices from all objects + kd_tree: KDTree + # All world vertices indexed by global vertex index (tuples for memory efficiency) + all_vertices: list[tuple[float, float, float]] + + +@dataclass +class NumericInputState: + """State for keyboard numeric input during gizmo operations.""" + + characters: list[str] + parsed_value: float + is_active: bool + is_valid: bool + + @classmethod + def create_default(cls) -> "NumericInputState": + return cls(characters=[], parsed_value=0.0, is_active=False, is_valid=True) + + def reset(self) -> None: + self.characters.clear() + self.parsed_value = 0.0 + self.is_active = False + self.is_valid = True + + def get_input_string(self) -> str: + return "".join(self.characters) + + def is_relative_mode(self) -> bool: + input_str = self.get_input_string() + return input_str.startswith("+") or input_str.startswith("-") + + def calculate_final_value(self, init_value: float, invert_delta: bool = False) -> float: + if self.is_relative_mode(): + delta = self.parsed_value + if invert_delta: + delta = -delta + return init_value + delta + return self.parsed_value + + def parse(self) -> None: + """Parse the current input string and update parsed_value and is_valid.""" + if not self.characters: + self.parsed_value = 0.0 + self.is_valid = True + return + + input_str = self.get_input_string() + is_valid, value = parse_distance_string(input_str) + + if is_valid: + self.parsed_value = value + self.is_valid = True + else: + try: + self.parsed_value = float(input_str) + self.is_valid = True + except ValueError: + self.parsed_value = 0.0 + self.is_valid = False + + +@dataclass +class DimensionGizmoConfig: + """Configuration for a dimension gizmo. + + Used to declaratively configure dimension line gizmos in BaseParametricGizmoGroup subclasses. + This enables a data-driven approach that reduces boilerplate code for setting up + dimension gizmos with consistent behavior. + + Color and prop_name are auto-derived if not specified: + - axis (1,0,0) or (-1,0,0) -> RED + - axis (0,1,0) or (0,-1,0) -> GREEN + - axis (0,0,1) or (0,0,-1) -> BLUE + - prop_name: "attr_name" -> "Attr Name" (underscores to spaces, title case) + + Examples: + # Basic dimension - uses attr_name to read/write property + DimensionGizmoConfig( + attr_name="overall_width", + axis=(1, 0, 0), + min_value=0.01, + ) + + # Custom value calculation - for computed properties + DimensionGizmoConfig( + attr_name="total_length", + axis=(1, 0, 0), + compute_value=lambda props: props.tread_run * props.num_treads, + apply_value=lambda props, val: setattr(props, "target_length", val), + ) + + # Conditional visibility - hide when not applicable + DimensionGizmoConfig( + attr_name="nosing_length", + axis=(-1, 0, 0), + visibility_condition=lambda props: props.nosing_length > 0, + ) + + Attributes: + attr_name: Property name to bind to (e.g., "overall_width"). Used to generate + gizmo attribute name as f"dimension_{attr_name}_gizmo". + axis: Direction tuple (x, y, z) for the dimension line. Determines color if not + specified and defines drag direction. Use negative values for reversed directions. + color: Optional override. One of "RED", "GREEN", "BLUE". Auto-derived from axis. + prop_name: Display name for tooltips. Defaults to attr_name with underscores + replaced by spaces and title-cased. + min_value: Minimum allowed value when dragging (default 0.0). + invert_delta: If True, reverses the drag direction effect. + delta_scale: Multiplier for drag delta (default 1.0). Use <1 for fine control. + text_offset_sign: 1 or -1 to position text above/below dimension line. + text_alignment: "start", "center", or "end" for text positioning along line. + show_start_arrow: Whether to show arrow at start point (default False). + show_end_arrow: Whether to show arrow at end point (default True). + compute_value: Optional function(props) -> float for computed dimension values. + If None, reads directly from getattr(props, attr_name). + apply_value: Optional function(props, value) to apply new values after drag. + If None, uses setattr(props, attr_name, value). + visibility_condition: Optional function(props) -> bool. If returns False, + the gizmo is hidden. Used for conditional gizmos. + """ + + attr_name: str + axis: tuple[int, int, int] + color: str | None = None + prop_name: str | None = None + min_value: float = 0.0 + invert_delta: bool = False + delta_scale: float = 1.0 + text_offset_sign: int = 1 + text_alignment: str = "center" + show_start_arrow: bool = False + show_end_arrow: bool = True + compute_value: Callable[[Any], float] | None = None + apply_value: Callable[[Any, float], None] | None = None + visibility_condition: Callable[[Any], bool] | None = None + + def __post_init__(self): + if self.color is None: + if self.axis[0] != 0: + self.color = "RED" + elif self.axis[1] != 0: + self.color = "GREEN" + else: + self.color = "BLUE" + + if self.prop_name is None: + self.prop_name = self.attr_name.replace("_", " ").title() + + +class SnapManager: + """Manages snap point visualization and mesh snapping with caching.""" + + def __init__(self): + self._snap_point: tuple[float, float, float] | Vector | None = None + self._draw_handler = None + self._shader = None + self._snap_cache: SnapCache | None = 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: + 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 as a dot.""" + 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_POINT_COLOR) + gpu.state.point_size_set(SNAP_POINT_SIZE) + + batch = batch_for_shader(self._shader, "POINTS", {"pos": [self._snap_point]}) + batch.draw(self._shader) + + gpu.state.point_size_set(DEFAULT_POINT_SIZE) + + @staticmethod + def _redraw_viewport() -> None: + """Force 3D viewport redraw.""" + for area in bpy.context.screen.areas: + if area.type == "VIEW_3D": + area.tag_redraw() + + def build_snap_cache( + self, context: bpy.types.Context, active_obj: bpy.types.Object, include_active: bool = False + ) -> None: + """Build unified cache with combined KD-tree for vertex snapping. + + Uses foreach_get for fast vertex data extraction and NumPy for + batch matrix transformation. + + Args: + context: The current Blender context. + active_obj: The active object being edited. + include_active: If True, include the active object's vertices in snapping targets. + """ + self._snap_cache = None + + mesh_objects = [obj for obj in context.visible_objects if obj.type == "MESH" and obj.visible_get()] + + if not include_active: + mesh_objects = [obj for obj in mesh_objects if obj != active_obj] + + if not mesh_objects: + return + + depsgraph = context.evaluated_depsgraph_get() + all_vertices: list[tuple[float, float, float]] = [] + + for obj in mesh_objects: + mesh_data = obj.data + if not hasattr(mesh_data, "vertices") or not mesh_data.vertices: + continue + + obj_eval = obj.evaluated_get(depsgraph) + mesh = obj_eval.to_mesh() + + try: + vertex_count = len(mesh.vertices) + if vertex_count == 0: + continue + + coords = np.empty(vertex_count * 3, dtype=np.float32) + mesh.vertices.foreach_get("co", coords) # type: ignore[arg-type] + coords = coords.reshape(-1, 3) + + matrix = np.array(obj_eval.matrix_world, dtype=np.float32) + ones = np.ones((vertex_count, 1), dtype=np.float32) + coords_h = np.hstack([coords, ones]) + world_coords = (coords_h @ matrix.T)[:, :3] + + all_vertices.extend(tuple(co) for co in world_coords) + finally: + obj_eval.to_mesh_clear() + + if not all_vertices: + return + + kd_tree = KDTree(len(all_vertices)) + for i, v in enumerate(all_vertices): + kd_tree.insert(v, i) + kd_tree.balance() + + self._snap_cache = SnapCache( + kd_tree=kd_tree, + all_vertices=all_vertices, + ) + + def clear_snap_cache(self) -> None: + self._snap_cache = None + + @staticmethod + def _calc_snap_distance_sq( + point_3d: Vector, + location: Vector, + mouse_vec: Vector | None, + region: bpy.types.Region | None, + rv3d: bpy.types.RegionView3D | None, + ) -> float: + """Calculate squared distance - screen-space if mouse coords available, else world-space.""" + if mouse_vec 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 (mouse_vec - point_2d).length_squared + return float("inf") + return (point_3d - location).length_squared + + @staticmethod + def _find_closest_vertex( + world_vertices: list[Vector], + location: Vector, + mouse_vec: Vector | None, + region: bpy.types.Region | None, + rv3d: bpy.types.RegionView3D | None, + closest_point: Vector | None, + closest_dist_sq: float, + kd_tree: KDTree | None = None, + ) -> tuple[Vector | None, float]: + """Find the closest vertex to snap to using KD-tree if available.""" + if kd_tree is not None: + for _, idx, _ in kd_tree.find_n(location, SNAP_KD_CANDIDATES): + v_co = world_vertices[idx] + dist_sq = SnapManager._calc_snap_distance_sq(v_co, location, mouse_vec, region, rv3d) + if dist_sq < closest_dist_sq: + closest_dist_sq = dist_sq + closest_point = v_co + else: + for v_co in world_vertices: + dist_sq = SnapManager._calc_snap_distance_sq(v_co, location, mouse_vec, region, rv3d) + if dist_sq < closest_dist_sq: + closest_dist_sq = dist_sq + closest_point = v_co + return closest_point, closest_dist_sq + + @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.""" + radius_sq = SNAP_MAX_RADIUS * SNAP_MAX_RADIUS + nearby_objects = [] + + for obj in mesh_objects: + bbox_corners = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box] + if not bbox_corners: + continue + + bbox_min = Vector( + ( + min(c.x for c in bbox_corners), + min(c.y for c in bbox_corners), + min(c.z for c in bbox_corners), + ) + ) + bbox_max = Vector( + ( + max(c.x for c in bbox_corners), + max(c.y for c in bbox_corners), + max(c.z for c in bbox_corners), + ) + ) + + closest = Vector( + ( + max(bbox_min.x, min(location.x, bbox_max.x)), + max(bbox_min.y, min(location.y, bbox_max.y)), + max(bbox_min.z, min(location.z, bbox_max.z)), + ) + ) + + if (location - closest).length_squared <= radius_sq: + nearby_objects.append(obj) + + return nearby_objects + + def snap_to_mesh( + self, + location: Vector, + context: bpy.types.Context, + active_obj: bpy.types.Object, + mouse_coords: tuple[float, float] | None = None, + include_active: bool = False, + ) -> Vector: + """Snap a location to the nearest vertex if snapping is enabled. + + Only vertex snapping is supported. Returns the original location if + snapping is disabled or VERTEX is not in the snap elements. + + Args: + location: The 3D location to snap from. + context: The current Blender context. + active_obj: The active object being edited. + mouse_coords: Optional mouse coordinates for screen-space distance. + include_active: If True, include the active object's vertices in snapping targets. + """ + tool_settings = context.scene.tool_settings + + if not tool_settings.use_snap: + return location + + if "VERTEX" not in tool_settings.snap_elements_base: + return location + + 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 + mouse_vec = Vector(mouse_coords) if mouse_coords is not None else None + + closest_point: Vector | None = None + closest_dist_sq = float("inf") + + if self._snap_cache is not None: + closest_point, closest_dist_sq = self._snap_from_cache(location, mouse_vec, region, rv3d) + else: + closest_point, closest_dist_sq = self._snap_without_cache( + location, context, active_obj, mouse_vec, region, rv3d, include_active + ) + + max_dist_sq = SNAP_SCREEN_DISTANCE**2 if use_screen_distance else SNAP_WORLD_DISTANCE**2 + if closest_point and closest_dist_sq < max_dist_sq: + return closest_point + + return location + + def _snap_from_cache( + self, + location: Vector, + mouse_vec: Vector | None, + region: bpy.types.Region | None, + rv3d: bpy.types.RegionView3D | None, + ) -> tuple[Vector | None, float]: + """Find closest vertex using cached KD-tree. + + Searches from both the target location AND a secondary location derived from + the mouse position to handle cases where click offset causes the mouse to be + far from the target location on screen. + """ + if self._snap_cache is None: + return None, float("inf") + + cache = self._snap_cache + + closest_point, closest_dist_sq = SnapManager._find_closest_vertex( + cache.all_vertices, location, mouse_vec, region, rv3d, None, float("inf"), cache.kd_tree + ) + + # Also search from mouse's 3D position to handle click offset cases + if mouse_vec is not None and region is not None and rv3d is not None: + mouse_origin = region_2d_to_origin_3d(region, rv3d, mouse_vec) + mouse_direction = region_2d_to_vector_3d(region, rv3d, mouse_vec) + view_distance = (location - mouse_origin).length + mouse_3d = mouse_origin + mouse_direction * view_distance + + closest_point, closest_dist_sq = SnapManager._find_closest_vertex( + cache.all_vertices, mouse_3d, mouse_vec, region, rv3d, closest_point, closest_dist_sq, cache.kd_tree + ) + + return closest_point, closest_dist_sq + + def _snap_without_cache( + self, + location: Vector, + context: bpy.types.Context, + active_obj: bpy.types.Object, + mouse_vec: Vector | None, + region: bpy.types.Region | None, + rv3d: bpy.types.RegionView3D | None, + include_active: bool = False, + ) -> tuple[Vector | None, float]: + """Find closest vertex without cache (fallback path).""" + mesh_objects = [obj for obj in context.visible_objects if obj.type == "MESH" and obj.visible_get()] + + if not include_active: + mesh_objects = [obj for obj in mesh_objects if obj != active_obj] + + if not mesh_objects: + return None, float("inf") + + nearby_objects = set(SnapManager._get_nearby_objects(mesh_objects, location)) + + if mouse_vec is not None and region is not None and rv3d is not None: + mouse_origin = region_2d_to_origin_3d(region, rv3d, mouse_vec) + mouse_direction = region_2d_to_vector_3d(region, rv3d, mouse_vec) + view_distance = (location - mouse_origin).length + mouse_3d = mouse_origin + mouse_direction * view_distance + nearby_objects.update(SnapManager._get_nearby_objects(mesh_objects, mouse_3d)) + + if not nearby_objects: + return None, float("inf") + + closest_point: Vector | None = None + closest_dist_sq = float("inf") + depsgraph = context.evaluated_depsgraph_get() + + for obj in nearby_objects: + mesh_data = obj.data + if not hasattr(mesh_data, "vertices") or not mesh_data.vertices: + continue + + obj_eval = obj.evaluated_get(depsgraph) + mesh = obj_eval.to_mesh() + + try: + if not mesh or not mesh.vertices: + continue + + world_vertices = [obj_eval.matrix_world @ v.co for v in mesh.vertices] + closest_point, closest_dist_sq = SnapManager._find_closest_vertex( + world_vertices, location, mouse_vec, region, rv3d, closest_point, closest_dist_sq + ) + finally: + obj_eval.to_mesh_clear() + + return closest_point, closest_dist_sq + + +_snap_manager = SnapManager() + + +def set_snap_point(point: tuple[float, float, float] | Vector | None) -> 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, + active_obj: bpy.types.Object, + mouse_coords: tuple[float, float] | None = None, + include_active: bool = False, +) -> Vector: + return _snap_manager.snap_to_mesh(location, context, active_obj, mouse_coords, include_active) + + +def build_snap_cache( + context: bpy.types.Context, active_obj: bpy.types.Object, include_active: bool = False +) -> None: + _snap_manager.build_snap_cache(context, active_obj, include_active) + + +def clear_snap_cache() -> None: + _snap_manager.clear_snap_cache() + + +def get_billboard_rotation(context: bpy.types.Context) -> Matrix: + """Get rotation matrix that makes an object face the camera.""" + rv3d = context.region_data + if rv3d is None: + return Matrix.Identity(4) + return rv3d.view_matrix.to_3x3().transposed().to_4x4() + + +def get_camera_direction(context: bpy.types.Context, position: Vector) -> Vector | None: + """Get normalized direction from position towards camera.""" + rv3d = context.region_data + if rv3d is None: + return None + + if rv3d.is_perspective: + view_origin = rv3d.view_matrix.inverted().translation + return (view_origin - position).normalized() + return Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized() + + +def generate_circle_vertices( + center: tuple[float, float, float] | Vector, radius: float, segments: int, plane: str = "XY" +) -> list[tuple[float, float, float]]: + """Generate circle vertices in specified plane ('XY', 'XZ', or 'YZ').""" + 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_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 circle arc with cross-section thickness for visibility 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 = 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 = angle_min_rad + angle_range * (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) + + +# ============================================================================ +# Gizmos under the hood +# ============================================================================ +# +# ## Transforms: +# +# source/blender/windowmanager/gizmo/WM_gizmo_types.h +# matrix_basis -- "Transformation of this gizmo." = placement in scene +# matrix_offset -- "Custom offset from origin." = local transforms according to state/value +# matrix_space -- "The space this gizmo is being modified in." used by some gizmos for undefined purposes +# matrix_world -- final matrix, scaled according to viewport zoom and custom scale_basis +# +# source/blender/windowmanager/gizmo/intern/wm_gizmo.c:WM_gizmo_calc_matrix_final_params +# final = space @ (autoscale * (basis @ offset)) +# final = space @ (basis @ offset) -- if gizmo.use_draw_scale == False +# final = space @ ((autoscale * basis) @ offset) -- if gizmo.use_draw_offset_scale +# +# source/blender/windowmanager/gizmo/intern/wm_gizmo.c:wm_gizmo_calculate_scale +# autoscale = gizmo.scale_basis * magic(preferences, matrix_space, matrix_basis, context.region_data) +# magic -- making 1.0 to match preferences.view.gizmo_size pixels (75 by default) +# +# +# ## Selection +# +# select_id -- apparently, id of a selectable part +# test_select -- expected to return id of selection, doesn't seem to work +# draw_select -- fake-draw of selection geometry for gpu-side cursor tracking +# ============================================================================ + + +# Some geometries for Gizmo.custom_shape shaders CUBE = ( (+1, +1, +1), @@ -250,13 +1551,11 @@ X3DISC = ( class CustomGizmo: - # FIXME: highliting/selection doesnt work + # FIXME: highlighting/selection doesn't work def draw_very_custom_shape(self, ctx, custom_shape, select_id=None): - # create shader and batch shader_wrapper, batch = custom_shape shader = shader_wrapper.get_shader() - # setup params shader.bind() if select_id is not None: gpu.select.load_id(select_id) @@ -269,11 +1568,8 @@ class CustomGizmo: shader_wrapper.glenable() shader_wrapper.uniform_region(ctx) - # using `with` block to make sure matrix multiplication - # won't affect other shaders with gpu.matrix.push_pop(): - # using matrix_world seems to be unaffected by matrix_offset - # therefore we use basis @ offset + # matrix_world is unaffected by matrix_offset, so use basis @ offset matrix = self.matrix_basis @ self.matrix_offset gpu.matrix.multiply_matrix(matrix) batch.draw(shader) @@ -355,7 +1651,7 @@ class UglyDotGizmo(OffsetHandle, types.Gizmo): def refresh(self): offset = self.target_get_value("offset") / self.scale_value - self.matrix_offset.translation.z = offset # z-shift + self.matrix_offset.translation.z = offset def draw(self, ctx): self.refresh() @@ -366,36 +1662,6 @@ class UglyDotGizmo(OffsetHandle, types.Gizmo): self.draw_custom_shape(self.custom_shape, select_id=select_id) -# TODO: dead code? -class DotGizmo(CustomGizmo, OffsetHandle, types.Gizmo): - """Single dot viewport-aligned""" - - # FIXME: make it selectable - bl_idname = "BIM_GT_dot_2d" - bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) - - __slots__ = ( - "scale_value", - "custom_shape", - ) - - def setup(self): - shader = DotsGizmoShader() - self.custom_shape = shader, shader.batch(pos=((0, 0, 0),)), shader.prog - self.use_draw_scale = False - - def refresh(self): - offset = self.target_get_value("offset") / self.scale_value - self.matrix_offset.translation.z = offset # z-shifted - - def draw(self, ctx): - self.refresh() - self.draw_very_custom_shape(ctx, self.custom_shape) - - def draw_select(self, ctx, select_id): - self.refresh() - self.draw_very_custom_shape(ctx, self.custom_shape, select_id=select_id) - class ExtrusionGuidesGizmo(CustomGizmo, types.Gizmo): """Extrusion guides @@ -430,48 +1696,6 @@ class ExtrusionGuidesGizmo(CustomGizmo, types.Gizmo): self.matrix_offset.col[2][2] = depth # z-scaled -# TODO: dead code? -class DimensionLabelGizmo(types.Gizmo): - """Text label for a dimension""" - - # does not work properly, fonts are totally screwed up - - bl_idname = "BIM_GT_dimension_label" - bl_target_properties = ({"id": "value", "type": "FLOAT", "array_length": 1},) - - __slots__ = "text_label" - - def setup(self): - pass - - def refresh(self, ctx): - value = self.target_get_value("value") - self.matrix_offset.translation.z = value * 0.5 - unit_system = ctx.scene.unit_settings.system - self.text_label = bpy.utils.units.to_string(unit_system, "LENGTH", value, 3, split_unit=False) - - def draw(self, ctx): - self.refresh(ctx) - self.draw_text(ctx) - - def draw_text(self, ctx): - font_id = 0 - font_size = 16 - dpi = ctx.preferences.system.dpi - - # pos = self.matrix_world @ Vector((0, 0, 0, 1)) - # pos = Vector((0, 0, 0.5)) - # region = ctx.region - # region3d = ctx.region_data - # pos = view3d_utils.location_3d_to_region_2d(region, region3d, pos) - - # text = self.text_label - - blf.size(font_id, font_size, dpi) - blf.position(font_id, 0, 0, 0) - blf.color(font_id, *self.color, self.alpha) - blf.draw(font_id, "ABC") - class ExtrusionWidget(types.GizmoGroup): bl_idname = "bim.extrusion_widget" @@ -492,9 +1716,11 @@ class ExtrusionWidget(types.GizmoGroup): def setup(self, context: bpy.types.Context) -> None: target = context.object - assert target + if not target: + return mesh = target.data - assert isinstance(mesh, bpy.types.Mesh) + if not isinstance(mesh, bpy.types.Mesh): + return prop = tool.Geometry.get_mesh_props(mesh).ifc_parameters.get("IfcExtrudedAreaSolid/Depth") basis = target.matrix_world.normalized() @@ -521,16 +1747,12 @@ class ExtrusionWidget(types.GizmoGroup): gz.target_set_prop("depth", prop, "value") gz.scale_value = scale_value - # gz = self.label = self.gizmos.new('GIZMO_GT_dimension_label') - # gz.matrix_basis = basis - # gz.color = tuple(theme.gizmo_secondary) - # gz.alpha = 0.5 - # gz.use_draw_modal = True - # gz.target_set_prop('value', target.demo, 'depth') def refresh(self, context: bpy.types.Context) -> None: """updating gizmos""" target = context.active_object + if not target: + return basis = target.matrix_world.normalized() self.handle.matrix_basis = basis self.guides.matrix_basis = basis @@ -539,10 +1761,14 @@ class ExtrusionWidget(types.GizmoGroup): """updating object""" bpy.ops.bim.update_parametric_representation() target = context.active_object - assert target + if not target: + return mesh = target.data - assert isinstance(mesh, bpy.types.Mesh) + if not isinstance(mesh, bpy.types.Mesh): + return prop = tool.Geometry.get_mesh_props(mesh).ifc_parameters.get("IfcExtrudedAreaSolid/Depth") + if prop is None: + return self.handle.target_set_prop("offset", prop, "value") self.guides.target_set_prop("depth", prop, "value") @@ -568,3 +1794,2273 @@ class ExtrusionWidget(types.GizmoGroup): elif length_unit == "THOU": scale_value /= si_conversions["thou"] return scale_value + +# ============================================================================ +# Core Gizmo Classes +# ============================================================================ + +class BIM_OT_gizmo_value_input(bpy.types.Operator): + """Enter a numeric value for a gizmo property. Click or Enter to confirm, ESC to cancel.""" + + bl_idname = "bim.gizmo_value_input" + bl_label = "Gizmo Value Input" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + prop_name: bpy.props.StringProperty(name="Property Name", default="Value") + init_value: bpy.props.FloatProperty(name="Initial Value", default=0.0) + invert_delta: bpy.props.BoolProperty(name="Invert Delta", default=False) + + def invoke(self, context, event): + self._keyboard_input = NumericInputState.create_default() + self._move_set_cb = _gizmo_modal_context.move_set_cb + self._active_gizmo = _gizmo_modal_context.active_gizmo + self._gizmo_group = _gizmo_modal_context.gizmo_group + self._hidden_gizmos: list[bpy.types.Gizmo] = [] + self._original_color: tuple[float, float, float] | None = None + + self._start_location: Vector = _gizmo_modal_context.start_location or Vector() + self._axis_direction: Vector = _gizmo_modal_context.axis_direction or Vector((0, 0, 1)) + self._active_obj: bpy.types.Object | None = _gizmo_modal_context.active_obj + self._delta_scale: float = _gizmo_modal_context.delta_scale + self._click_offset: float = _gizmo_modal_context.click_offset + self._mouse_delta: float = 0.0 + + self._initial_snap_state: bool = context.scene.tool_settings.use_snap + self._snap_cache_built: bool = False + self._is_snapping: bool = False + + self._hide_other_gizmos() + self._set_highlight_color() + + context.window_manager.modal_handler_add(self) + self._update_header(context) + return {"RUNNING_MODAL"} + + def _set_highlight_color(self) -> None: + if not self._active_gizmo: + return + self._original_color = tuple(self._active_gizmo.color) + self._active_gizmo.color = self._active_gizmo.color_highlight + + def _restore_color(self) -> None: + if self._active_gizmo and self._original_color: + self._active_gizmo.color = self._original_color + + def _hide_other_gizmos(self) -> None: + if not self._gizmo_group or not self._active_gizmo: + return + + hidden_set: set[bpy.types.Gizmo] = set() + for gizmo in self._gizmo_group.gizmos: + if gizmo != self._active_gizmo: + gizmo.hide = True + hidden_set.add(gizmo) + self._hidden_gizmos.append(gizmo) + + _gizmo_modal_context.hidden_gizmos = hidden_set + + def _restore_gizmo_visibility(self) -> None: + _gizmo_modal_context.hidden_gizmos = None + for gizmo in self._hidden_gizmos: + gizmo.hide = False + self._hidden_gizmos.clear() + + def modal(self, context, event): + kb = self._keyboard_input + + if event.value == "PRESS" and event.ascii and event.ascii.lower() in NUMERIC_INPUT_CHARS: + kb.characters.append(event.ascii) + kb.is_active = True + kb.parse() + self._apply_value() + self._update_header(context) + return {"RUNNING_MODAL"} + + if event.type == "BACK_SPACE" and event.value == "PRESS": + if kb.characters: + kb.characters.pop() + kb.parse() + self._apply_value() + self._update_header(context) + return {"RUNNING_MODAL"} + + if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS": + if kb.is_valid: + self._apply_value() + self._cleanup(context) + return {"FINISHED"} + + if event.type == "LEFTMOUSE" and event.value == "PRESS": + if kb.characters: + if kb.is_valid: + self._apply_value() + self._cleanup(context) + return {"FINISHED"} + self._cleanup(context) + return {"CANCELLED"} + + if event.type == "ESC" and event.value == "PRESS": + if self._move_set_cb: + self._move_set_cb(self.init_value) + self._cleanup(context) + return {"CANCELLED"} + + if event.type == "RIGHTMOUSE" and event.value == "PRESS": + if self._move_set_cb: + self._move_set_cb(self.init_value) + self._cleanup(context) + return {"CANCELLED"} + + if event.type == "MOUSEMOVE" and not kb.characters: + self._handle_mouse_move(context, event) + return {"RUNNING_MODAL"} + + return {"RUNNING_MODAL"} + + def _handle_mouse_move(self, context, event) -> None: + region = context.region + rv3d = context.region_data + tool_settings = context.scene.tool_settings + if not region or not rv3d: + return + + self._is_snapping = not self._initial_snap_state if event.ctrl else self._initial_snap_state + + if self._is_snapping and not self._snap_cache_built and self._active_obj: + build_snap_cache(context, self._active_obj) + self._snap_cache_built = True + + 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) + + result = intersect_line_line( + view_origin, + view_origin + view_direction * RAY_CAST_DISTANCE, + self._start_location, + self._start_location + self._axis_direction * RAY_CAST_DISTANCE, + ) + current_3d = result[1] if result else self._start_location + + delta = (current_3d - self._start_location).dot(self._axis_direction) + + # Snap the dimension tip (not mouse position) to nearby vertices + if self._is_snapping and self._active_obj: + tip_3d = current_3d - self._axis_direction * self._click_offset + + original_snap = tool_settings.use_snap + tool_settings.use_snap = True + snapped_tip = snap_to_mesh(tip_3d, context, self._active_obj, current_coord) + tool_settings.use_snap = original_snap + + if snapped_tip != tip_3d: + delta = (snapped_tip - self._start_location).dot(self._axis_direction) + self._click_offset + set_snap_point(snapped_tip) + else: + clear_snap_point() + else: + clear_snap_point() + + if event.shift: + delta *= PRECISION_MODE_MULTIPLIER + + if self.invert_delta: + delta = -delta + + delta *= self._delta_scale + self._mouse_delta = delta + + if self._move_set_cb: + self._move_set_cb(self.init_value + delta) + + self._update_header(context) + + def _apply_value(self) -> None: + if not self._move_set_cb or not self._keyboard_input.is_valid: + return + final_value = self._keyboard_input.calculate_final_value(self.init_value, self.invert_delta) + self._move_set_cb(final_value) + + def _update_header(self, context) -> None: + if not context.area: + return + kb = self._keyboard_input + + if kb.characters: + input_str = kb.get_input_string() + preview = kb.calculate_final_value(self.init_value, self.invert_delta) + validity = "" if kb.is_valid else " [invalid]" + header = f"{self.prop_name}: {preview:.3f}m | Input: {input_str}_{validity}" + header += " | Click/Enter: Confirm | ESC: Cancel" + else: + current_value = self.init_value + self._mouse_delta + header = f"{self.prop_name}: {current_value:.3f}m" + hints = [] + if self._is_snapping: + hints.append("Snapping: ON") + hints.extend(["Ctrl: Snap", "Shift: Precision", "Type: Enter Value"]) + header += " | " + " | ".join(hints) + header += " | Click/Enter: Confirm | ESC: Cancel" + + context.area.header_text_set(header) + + def _cleanup(self, context) -> None: + try: + if context.area: + context.area.header_text_set(None) + finally: + try: + self._restore_color() + finally: + try: + self._restore_gizmo_visibility() + if self._gizmo_group and hasattr(self._gizmo_group, "refresh"): + self._gizmo_group.refresh(context) + if context.area: + context.area.tag_redraw() + finally: + clear_snap_point() + clear_snap_cache() + _gizmo_modal_context.clear() + + +class GizmoMovable(bpy.types.Gizmo): + """Base class for draggable gizmos. Ctrl: snap, Shift: precision, Keyboard: direct input. + + Click without dragging enters a keyboard-only input mode for accessibility. + """ + + __slots__ = ( + "custom_shape", + "init_value", + "move_get_cb", + "move_set_cb", + "axis", + "local_axis", + "start_location", + "active_obj", + "initial_snap_state", + "invert_delta", + "delta_scale", + "prop_name", + "keyboard_input", + "gizmo_group", + "_snap_cache_built", + "_start_mouse_pos", + "_has_dragged", + ) + + # Class-level cached shader (created once, reused across all instances) + _cached_tri_shader = None + + @classmethod + def _get_tri_shader(cls): + """Get cached UNIFORM_COLOR shader for triangles.""" + if cls._cached_tri_shader is None: + cls._cached_tri_shader = gpu.shader.from_builtin("UNIFORM_COLOR") + return cls._cached_tri_shader + + # Threshold in pixels for considering mouse movement as a drag + DRAG_THRESHOLD = 5 + + 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 + self.keyboard_input = NumericInputState.create_default() + self._snap_cache_built = False + self._start_mouse_pos = (event.mouse_region_x, event.mouse_region_y) + self._has_dragged = False + if not hasattr(self, "prop_name") or self.prop_name is None: + self.prop_name = "Value" + prop_name = getattr(self, "prop_name", "Value") + bpy.ops.ed.undo_push(message=f"Gizmo: {prop_name}") + if self.initial_snap_state and self.active_obj: + build_snap_cache(context, self.active_obj) + self._snap_cache_built = True + + self._hide_other_gizmos() + + return {"RUNNING_MODAL"} + + def _hide_other_gizmos(self) -> None: + """Hide all other gizmos in the group during interaction.""" + gizmo_group = getattr(self, "gizmo_group", None) + if not gizmo_group: + return + + hidden_set: set[bpy.types.Gizmo] = set() + for gizmo in gizmo_group.gizmos: + if gizmo != self: + gizmo.hide = True + hidden_set.add(gizmo) + + _gizmo_modal_context.hidden_gizmos = hidden_set + + def _restore_gizmo_visibility(self) -> None: + """Restore visibility of gizmos hidden during interaction.""" + _gizmo_modal_context.hidden_gizmos = None + + def exit(self, context: bpy.types.Context, cancel: bool) -> None: + if context.area: + context.area.header_text_set(None) + if hasattr(self, "keyboard_input"): + self.keyboard_input.reset() + + should_invoke_keyboard = ( + not cancel + and hasattr(self, "_has_dragged") + and not self._has_dragged + and self.move_set_cb is not None + ) + + if should_invoke_keyboard: + _gizmo_modal_context.move_set_cb = self.move_set_cb + _gizmo_modal_context.active_gizmo = self + _gizmo_modal_context.gizmo_group = getattr(self, "gizmo_group", None) + _gizmo_modal_context.start_location = self.start_location.copy() + _gizmo_modal_context.axis_direction = self.get_axis_direction() + _gizmo_modal_context.active_obj = self.active_obj + _gizmo_modal_context.delta_scale = getattr(self, "delta_scale", 1.0) + bpy.ops.bim.gizmo_value_input( + "INVOKE_DEFAULT", + prop_name=getattr(self, "prop_name", "Value"), + init_value=self.init_value, + invert_delta=getattr(self, "invert_delta", False), + ) + elif cancel and self.move_set_cb: + self.move_set_cb(self.init_value) + + if not should_invoke_keyboard: + self._restore_gizmo_visibility() + + if hasattr(self, "initial_snap_state"): + context.scene.tool_settings.use_snap = self.initial_snap_state + clear_snap_point() + clear_snap_cache() + + def get_axis_direction(self) -> Vector: + """Get the world-space axis direction, transforming local_axis if set.""" + if hasattr(self, "local_axis") and self.active_obj: + obj_rotation = self.active_obj.matrix_world.to_3x3() + axis_direction: Vector = obj_rotation @ self.local_axis + axis_direction.normalize() + return axis_direction + 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 + + keyboard_result = self._handle_keyboard_input(context, event) + if keyboard_result is not None: + return keyboard_result + + if self.keyboard_input.is_active: + return {"RUNNING_MODAL"} + + if not region or not rv3d: + return {"RUNNING_MODAL"} + + tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state + + if tool_settings.use_snap and not self._snap_cache_built and self.active_obj: + build_snap_cache(context, self.active_obj) + self._snap_cache_built = True + + current_coord = (event.mouse_region_x, event.mouse_region_y) + + if not self._has_dragged and hasattr(self, "_start_mouse_pos"): + dx = current_coord[0] - self._start_mouse_pos[0] + dy = current_coord[1] - self._start_mouse_pos[1] + if (dx * dx + dy * dy) > (self.DRAG_THRESHOLD ** 2): + self._has_dragged = True + 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 * RAY_CAST_DISTANCE, + self.start_location, + 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, self.active_obj, current_coord) + 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 getattr(self, "invert_delta", False): + delta = -delta + + delta_scale = getattr(self, "delta_scale", 1.0) + delta *= delta_scale + + kb = self.keyboard_input + final_delta = kb.parsed_value if kb.parsed_value != 0.0 else delta + + if self.move_set_cb: + self.move_set_cb(self.init_value + final_delta) + + self._update_header(context, self.init_value + final_delta, tool_settings.use_snap, event.shift) + + return {"RUNNING_MODAL"} + + def _handle_keyboard_input( + self, context: bpy.types.Context, event: bpy.types.Event + ) -> set[str] | None: + """Handle keyboard numeric input.""" + kb = self.keyboard_input + + if event.value == "PRESS" and event.ascii and event.ascii.lower() in NUMERIC_INPUT_CHARS: + kb.characters.append(event.ascii) + kb.is_active = True + kb.parse() + self._apply_keyboard_value() + self._update_header_typing(context) + return {"RUNNING_MODAL"} + + if event.type == "BACK_SPACE" and event.value == "PRESS": + if kb.characters: + kb.characters.pop() + kb.parse() + self._apply_keyboard_value() + self._update_header_typing(context) + elif kb.is_active: + kb.reset() + if self.move_set_cb: + self.move_set_cb(self.init_value) + return {"RUNNING_MODAL"} + + if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS": + if kb.is_active and kb.is_valid: + final_value = kb.calculate_final_value(self.init_value, getattr(self, "invert_delta", False)) + kb.characters.clear() + kb.is_active = False + self._update_header(context, final_value, False, False) + return {"RUNNING_MODAL"} + + if event.type == "ESC" and event.value == "PRESS" and kb.is_active: + kb.reset() + if self.move_set_cb: + self.move_set_cb(self.init_value) + return {"RUNNING_MODAL"} + + return None + + def _apply_keyboard_value(self) -> None: + kb = self.keyboard_input + if self.move_set_cb and kb.is_valid: + final_value = kb.calculate_final_value(self.init_value, getattr(self, "invert_delta", False)) + self.move_set_cb(final_value) + + def _update_header_typing(self, context: bpy.types.Context) -> None: + if not context.area: + return + kb = self.keyboard_input + input_str = kb.get_input_string() + preview = kb.calculate_final_value(self.init_value, getattr(self, "invert_delta", False)) + + validity = "" if kb.is_valid else " [invalid]" + prop_display = getattr(self, "prop_name", "Value") + + header = f"{prop_display}: {preview:.3f}m | Input: {input_str}_{validity}" + header += " | Enter: Confirm | Backspace: Delete | ESC: Cancel" + context.area.header_text_set(header) + + def _update_header(self, context: bpy.types.Context, value: float, is_snapping: bool, is_precision: bool) -> None: + if not context.area: + return + prop_display = getattr(self, "prop_name", "Value") + hints = [] + if is_snapping: + hints.append("Snapping: ON") + if is_precision: + hints.append("Precision (0.1x)") + hints.extend(["Ctrl: Snap", "Shift: Precision", "Type: Enter Value"]) + + header_text = f"{prop_display}: {value:.3f}m | " + " | ".join(hints) + context.area.header_text_set(header_text) + + def draw_property_tooltip(self, context: bpy.types.Context) -> None: + """Draw a tooltip showing the property name near the gizmo when highlighted.""" + if not self.is_highlight: + return + if not hasattr(self, "prop_name") or not self.prop_name: + return + + region = context.region + rv3d = context.region_data + if not region or not rv3d: + return + + gizmo_pos = self.matrix_basis.translation + screen_pos = location_3d_to_region_2d(region, rv3d, gizmo_pos) + if not screen_pos: + return + + prop_display = self.prop_name.replace("_", " ").title() + + font_id = 0 + font_size = tool.Blender.scale_font_size(10) + blf.size(font_id, font_size) + blf.enable(font_id, blf.SHADOW) + blf.shadow(font_id, 6, 0, 0, 0, 1) + + text_width, text_height = blf.dimensions(font_id, prop_display) + + tooltip_x = screen_pos[0] + 15 + tooltip_y = screen_pos[1] + 15 + + with GPUStateScope(depth_test="NONE", blend="ALPHA", ortho_2d=(region.width, region.height)): + padding = 3 + theme = context.preferences.themes.items()[0][1] + bg_color = (*theme.user_interface.wcol_menu_back.inner[:3], 0.7) + + vertices = [ + (tooltip_x - padding, tooltip_y - padding), + (tooltip_x + text_width + padding, tooltip_y - padding), + (tooltip_x + text_width + padding, tooltip_y + text_height + padding), + (tooltip_x - padding, tooltip_y + text_height + padding), + ] + indices = [(0, 1, 2), (0, 2, 3)] + + shader = self._get_tri_shader() + shader.bind() + batch = batch_for_shader(shader, "TRIS", {"pos": vertices}, indices=indices) + shader.uniform_float("color", bg_color) + batch.draw(shader) + + blf.color(font_id, self.color_highlight[0], self.color_highlight[1], self.color_highlight[2], 1.0) + blf.position(font_id, tooltip_x, tooltip_y, 0) + blf.draw(font_id, prop_display) + + blf.disable(font_id, blf.SHADOW) + + +class GizmoLock(bpy.types.Gizmo): + """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.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.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) -> object: + """Get the appropriate custom shape based on lock state.""" + 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 (ValueError, KeyError, AttributeError): + 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): + """Arc gizmo for door swing visualization.""" + + bl_idname = "VIEW3D_GT_arc" + + __slots__ = ( + "custom_shape_left", + "custom_shape_right", + "prop_path", + ) + + def setup(self) -> None: + """Create arc shapes for 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) -> object: + """Get arc shape based on door swing direction.""" + 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 (ValueError, KeyError, AttributeError): + 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): + """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): + """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): + """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 GizmoPlus(bpy.types.Gizmo): + """Plus icon gizmo for incrementing values.""" + + bl_idname = "VIEW3D_GT_plus" + + __slots__ = ("custom_shape",) + + 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), + (-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): + """Minus icon gizmo for decrementing values.""" + + bl_idname = "VIEW3D_GT_minus" + + __slots__ = ("custom_shape",) + + 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 covering ~300 degrees.""" + triangles = [] + radius = 0.375 + line_width = 0.10 + half_width = line_width / 2 + + segments = 20 + start_angle = math.radians(30) + end_angle = math.radians(330) + angle_range = end_angle - start_angle + + 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)) + + 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 + + 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), + ] + ) + + 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), + ] + ) + + arrow_size = 0.30 + end_x, end_y = arc_points[-1] + 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 + + tip_x = end_x + tangent_x * arrow_size * 0.5 + tip_y = end_y + tangent_y * arrow_size * 0.5 + + perp_x = -tangent_y * arrow_size + perp_y = tangent_x * arrow_size + + 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), + ] + ) + + 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): + """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.""" + + 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], ...]: + 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) + self.draw_property_tooltip(context) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self.draw_custom_shape(self.custom_shape, select_id=select_id) + + +class GizmoArrow2D(GizmoMovable): + """Flat 2D arrow that rotates around its axis to face the camera.""" + + bl_idname = "BIM_GT_gizmo_arrow_2d" + bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) + + ARROW_2D_SHAFT_LENGTH = 0.5 + ARROW_2D_HEAD_LENGTH = 0.5 + ARROW_2D_WIDTH = 0.25 + ARROW_2D_HEAD_WIDTH = 0.75 + + def _get_arrow_2d_triangles(self) -> tuple[tuple[float, float, float], ...]: + """Generate flat arrow geometry in XY plane, pointing along +X.""" + shaft = self.ARROW_2D_SHAFT_LENGTH + head = self.ARROW_2D_HEAD_LENGTH + w = self.ARROW_2D_WIDTH / 2 + hw = self.ARROW_2D_HEAD_WIDTH / 2 + + return ( + # Shaft + (0, -w, 0), (shaft, -w, 0), (0, w, 0), + (0, w, 0), (shaft, -w, 0), (shaft, w, 0), + # Head + (shaft, -hw, 0), (shaft + head, 0, 0), (shaft, hw, 0), + ) + + def setup(self) -> None: + self.custom_shape = self.new_custom_shape("TRIS", self._get_arrow_2d_triangles()) + + def draw(self, context: bpy.types.Context) -> None: + self.draw_custom_shape(self.custom_shape) + self.draw_property_tooltip(context) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self.draw_custom_shape(self.custom_shape, select_id=select_id) + + def draw_prepare(self, context: bpy.types.Context) -> None: + """Rotate around arrow axis to face camera.""" + position = self.matrix_basis.translation + to_camera = get_camera_direction(context, position) + if to_camera is None: + return + + axis_world = Vector(self.matrix_basis.col[0][:3]).normalized() + to_camera_projected = to_camera - axis_world * to_camera.dot(axis_world) + + if to_camera_projected.length_squared < 1e-6: + return + + to_camera_projected.normalize() + + local_z_world = Vector(self.matrix_basis.col[2][:3]).normalized() + local_z_projected = local_z_world - axis_world * local_z_world.dot(axis_world) + + if local_z_projected.length_squared < 1e-6: + return + + local_z_projected.normalize() + + cross = local_z_projected.cross(to_camera_projected) + dot = local_z_projected.dot(to_camera_projected) + sign = 1.0 if cross.dot(axis_world) >= 0 else -1.0 + angle = sign * math.acos(max(-1.0, min(1.0, dot))) + + axis_rot = Matrix.Rotation(angle, 4, "X") + current_scale = self.matrix_offset.to_scale() if self.matrix_offset else Vector((1, 1, 1)) + self.matrix_offset = axis_rot @ Matrix.Scale(current_scale[0], 4) + + +class GizmoCone(GizmoMovable): + """Cone gizmo for directional value editing.""" + + bl_idname = "BIM_GT_gizmo_cone" + bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) + + def _get_cone_triangles(self) -> tuple[tuple[float, float, float], ...]: + 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) + + +class GizmoDimension(GizmoMovable): + """Dimension line gizmo that displays a measurement with extension lines and text. + + The dimension line is drawn from (0, 0, 0) to (length, 0, 0) in local space, + with extension lines at both ends and a text label showing the formatted value. + Clicking on the dimension line allows editing the value similar to arrow gizmos. + + The arrows, extension lines, and text are drawn in screen space for constant size, + while the main dimension line spans the actual world-space distance. + """ + + bl_idname = "BIM_GT_gizmo_dimension" + bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) + + # Class-level cached shaders (created once, reused across all instances) + _cached_line_shader = None + _cached_tri_shader = None + + @classmethod + def _get_line_shader(cls): + """Get cached POLYLINE_UNIFORM_COLOR shader.""" + if cls._cached_line_shader is None: + cls._cached_line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + return cls._cached_line_shader + + @classmethod + def _get_tri_shader(cls): + """Get cached UNIFORM_COLOR shader for triangles.""" + if cls._cached_tri_shader is None: + cls._cached_tri_shader = gpu.shader.from_builtin("UNIFORM_COLOR") + return cls._cached_tri_shader + + __slots__ = ( + "custom_shape", + "init_value", + "move_get_cb", + "move_set_cb", + "axis", + "local_axis", + "start_location", + "active_obj", + "initial_snap_state", + "invert_delta", + "delta_scale", + "prop_name", + "keyboard_input", + "gizmo_group", + "_snap_cache_built", + "_start_mouse_pos", + "_has_dragged", + "_dimension_length", + "text_offset_sign", # -1 to offset text below/left, +1 for above/right (default) + "show_start_arrow", # Whether to show arrow at start (origin) of dimension + "show_end_arrow", # Whether to show arrow at end of dimension + "text_alignment", # "center" (default) or "start" (left-aligned at offset from line) + "_original_value", # Original property value before interaction + "_click_offset", # Offset from dimension tip to click position (for snap correction) + "show_extension_lines", # Whether to show extension lines at dimension endpoints + ) + + ARROW_SIZE = 10 + EXTENSION_LENGTH = 4 + LINE_WIDTH = 2.0 + MIN_PIXELS_FOR_DETAILS = 35 + HIT_WIDTH = 0.03 + MIN_HIT_LENGTH = 0.05 + + def _get_clickable_shape(self) -> tuple[tuple[float, float, float], ...]: + """Generate a simple clickable bar shape (unit length along X).""" + hw = self.HIT_WIDTH / 2 + return ( + (0, -hw, -hw), (1, -hw, -hw), (0, hw, -hw), + (0, hw, -hw), (1, -hw, -hw), (1, hw, -hw), + (0, -hw, hw), (0, hw, hw), (1, -hw, hw), + (1, -hw, hw), (0, hw, hw), (1, hw, hw), + (0, -hw, -hw), (0, -hw, hw), (1, -hw, -hw), + (1, -hw, -hw), (0, -hw, hw), (1, -hw, hw), + (0, hw, -hw), (1, hw, -hw), (0, hw, hw), + (0, hw, hw), (1, hw, -hw), (1, hw, hw), + ) + + def setup(self) -> None: + self.custom_shape = self.new_custom_shape("TRIS", self._get_clickable_shape()) + self._dimension_length = 1.0 + self.text_offset_sign = 1 + self.show_start_arrow = False + self.show_end_arrow = True + self.text_alignment = "center" + self.show_extension_lines = True + + def draw(self, context: bpy.types.Context) -> None: + """Draw dimension graphics using the DimensionRenderer singleton.""" + if not hasattr(self, "_dimension_length") or self._dimension_length < 0: + return + + axis_world = Vector(self.matrix_basis.col[0][:3]).normalized() + start_world = self.matrix_basis.translation.copy() + end_world = start_world + axis_world * self._dimension_length + + DimensionRenderer.get_instance().draw( + context=context, + start_world=start_world, + end_world=end_world, + axis_world=axis_world, + dimension_length=self._dimension_length, + color=(self.color[0], self.color[1], self.color[2]), + alpha=self.alpha, + is_highlight=self.is_highlight, + highlight_color=(self.color_highlight[0], self.color_highlight[1], self.color_highlight[2]), + highlight_alpha=self.alpha_highlight, + show_start_arrow=getattr(self, "show_start_arrow", False), + show_end_arrow=getattr(self, "show_end_arrow", True), + show_extension_lines=getattr(self, "show_extension_lines", True), + text_offset_sign=getattr(self, "text_offset_sign", 1), + text_alignment=getattr(self, "text_alignment", "center"), + prop_name=getattr(self, "prop_name", None), + ) + + def _calculate_screen_endpoints( + self, context: bpy.types.Context + ) -> tuple[Vector, Vector, Vector, float] | None: + """Calculate screen-space endpoints and direction for the dimension line. + + Returns: + Tuple of (start_screen, end_screen, direction, length_screen) or None if off-screen + """ + region = context.region + rv3d = context.region_data + if not region or not rv3d: + return None + + axis_world = Vector(self.matrix_basis.col[0][:3]).normalized() + start_world = self.matrix_basis.translation.copy() + end_world = start_world + axis_world * self._dimension_length + + start_screen = location_3d_to_region_2d(region, rv3d, start_world) + end_screen = location_3d_to_region_2d(region, rv3d, end_world) + + if not start_screen or not end_screen: + return None + + direction = Vector((end_screen[0] - start_screen[0], end_screen[1] - start_screen[1])) + length_screen = direction.length + + actual_value_is_zero = self._dimension_length <= 0.001 + + # When screen length is zero due to viewing angle (not actual value being 0), skip drawing + if length_screen < 1 and not actual_value_is_zero: + return None + + # When actual value is zero, determine direction from 3D axis projection + if length_screen < 1 and actual_value_is_zero: + test_world = start_world + axis_world * 0.1 + test_screen = location_3d_to_region_2d(region, rv3d, test_world) + if test_screen: + direction = Vector((test_screen[0] - start_screen[0], test_screen[1] - start_screen[1])) + if direction.length > 0.001: + direction.normalize() + else: + direction = Vector((1, 0)) + else: + direction = Vector((1, 0)) + else: + direction.normalize() + + return (Vector(start_screen), Vector(end_screen), direction, length_screen) + + def _build_arrow_triangle( + self, position: Vector, direction: Vector, perpendicular: Vector, pointing_backward: bool + ) -> list[tuple[float, float]]: + """Build triangle vertices for an arrow head. + + Args: + position: Screen position of the arrow tip + direction: Normalized direction vector of the dimension line + perpendicular: Perpendicular vector for arrow width + pointing_backward: If True, arrow points opposite to direction (for end arrow) + + Returns: + List of 3 vertex tuples forming the arrow triangle + """ + sign = -1 if pointing_backward else 1 + arrow_tip = (position[0], position[1]) + arrow_back_left = ( + position[0] + sign * direction[0] * self.ARROW_SIZE + perpendicular[0] * self.ARROW_SIZE * 0.5, + position[1] + sign * direction[1] * self.ARROW_SIZE + perpendicular[1] * self.ARROW_SIZE * 0.5, + ) + arrow_back_right = ( + position[0] + sign * direction[0] * self.ARROW_SIZE - perpendicular[0] * self.ARROW_SIZE * 0.5, + position[1] + sign * direction[1] * self.ARROW_SIZE - perpendicular[1] * self.ARROW_SIZE * 0.5, + ) + return [arrow_tip, arrow_back_left, arrow_back_right] + + def _build_extension_line_vertices( + self, position: Vector, perpendicular: Vector + ) -> tuple[tuple[float, float], tuple[float, float]]: + """Build extension line endpoints perpendicular to the dimension at given position. + + Returns: + Tuple of (top_vertex, bottom_vertex) + """ + top = ( + position[0] + perpendicular[0] * self.EXTENSION_LENGTH, + position[1] + perpendicular[1] * self.EXTENSION_LENGTH, + ) + bottom = ( + position[0] - perpendicular[0] * self.EXTENSION_LENGTH, + position[1] - perpendicular[1] * self.EXTENSION_LENGTH, + ) + return (top, bottom) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + # Scale the clickable shape to match the dimension length + # The custom_shape is unit length (0 to 1), so we need to scale by _dimension_length + # Use MIN_HIT_LENGTH to ensure small dimensions are still clickable + length = getattr(self, "_dimension_length", 1.0) + hit_length = max(length, self.MIN_HIT_LENGTH) + + # Save original matrix_offset and apply length scaling along local X axis + # Use matrix_offset for local transforms as per Blender gizmo conventions + original_offset = self.matrix_offset.copy() if self.matrix_offset else Matrix.Identity(4) + self.matrix_offset = Matrix.Diagonal((hit_length, 1.0, 1.0, 1.0)) + + self.draw_custom_shape(self.custom_shape, select_id=select_id) + + self.matrix_offset = original_offset + + def set_dimension_length(self, length: float) -> None: + """Set the length of the dimension line with validation.""" + # Validate input: reject NaN, Inf, and non-numeric values + if not isinstance(length, (int, float)) or math.isnan(length) or math.isinf(length): + length = 0.0 + # Clamp to valid range (0 to 10000 meters is reasonable for BIM) + self._dimension_length = max(0.0, min(length, 10000.0)) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set: + """Initialize dimension gizmo interaction with click-position tracking. + + Click-position tracking prevents jarring value jumps when clicking: + + 1. Calculate where user clicked on the dimension axis (click_distance) + 2. Set start_location at the click position so delta=0 there + 3. Store _original_value for cancel/restore functionality + 4. Set init_value = click_distance for direct position mapping + + Result: When dragging, value = click_distance + mouse_delta, giving + intuitive "drag to position" behavior without any initial jump. + The value only changes when the user actually drags. + """ + self.init_value = self.move_get_cb() if self.move_get_cb else 0.0 + self.active_obj = context.active_object + self.initial_snap_state = context.scene.tool_settings.use_snap + self.keyboard_input = NumericInputState.create_default() + self._snap_cache_built = False + self._has_dragged = False + self._start_mouse_pos = (event.mouse_region_x, event.mouse_region_y) + if not hasattr(self, "prop_name") or self.prop_name is None: + self.prop_name = "Value" + + # Push undo step before making changes so Ctrl+Z can restore original state + prop_name = getattr(self, "prop_name", "Value") + bpy.ops.ed.undo_push(message=f"Gizmo: {prop_name}") + + axis_world = Vector(self.matrix_basis.col[0][:3]).normalized() + gizmo_origin = self.matrix_basis.translation.copy() + + # Calculate where user clicked on the axis + region = context.region + rv3d = context.region_data + click_distance = self.init_value # Default: assume click at current value endpoint + + if region and rv3d: + click_coord = (event.mouse_region_x, event.mouse_region_y) + view_origin = region_2d_to_origin_3d(region, rv3d, click_coord) + view_direction = region_2d_to_vector_3d(region, rv3d, click_coord) + + result = intersect_line_line( + view_origin, + view_origin + view_direction * RAY_CAST_DISTANCE, + gizmo_origin, + gizmo_origin + axis_world * RAY_CAST_DISTANCE, + ) + + if result: + click_on_axis = result[1] + click_distance = (click_on_axis - gizmo_origin).dot(axis_world) + + # Set start_location at the click position on the axis + # This makes delta=0 when mouse is at click position + self.start_location = gizmo_origin + axis_world * click_distance + + # With start_location at click point and init_value = click_distance: + # - When mouse moves to position p: delta = p - click_distance + # - final_value = click_distance + (p - click_distance) = p + # This gives us direct "position = value" behavior. + # The modal only updates after user has dragged, preserving original value until then. + self._original_value = self.init_value + + # Why _click_offset: Users rarely click exactly on the dimension tip. Without this + # correction, the value would jump to match cursor position. By storing the offset + # between click position and actual tip, we can subtract it during modal updates + # so the dimension "sticks" to the cursor naturally without initial jumps. + self._click_offset = click_distance - self._original_value + + self.init_value = click_distance + + if self.initial_snap_state and self.active_obj: + build_snap_cache(context, self.active_obj) + self._snap_cache_built = True + + self._hide_other_gizmos() + + return {"RUNNING_MODAL"} + + def _hide_other_gizmos(self) -> None: + """Hide all other gizmos in the group during interaction.""" + gizmo_group = getattr(self, "gizmo_group", None) + if not gizmo_group: + return + + hidden_set: set[bpy.types.Gizmo] = set() + for gizmo in gizmo_group.gizmos: + if gizmo != self: + gizmo.hide = True + hidden_set.add(gizmo) + + _gizmo_modal_context.hidden_gizmos = hidden_set + + def _restore_gizmo_visibility(self) -> None: + """Restore visibility of gizmos hidden during interaction.""" + _gizmo_modal_context.hidden_gizmos = None + + def modal(self, context: bpy.types.Context, event: bpy.types.Event, tweak) -> set: + """Override modal to apply click offset and prevent value jumps.""" + region = context.region + rv3d = context.region_data + tool_settings = context.scene.tool_settings + + keyboard_result = self._handle_keyboard_input(context, event) + if keyboard_result is not None: + return keyboard_result + + if self.keyboard_input.is_active: + return {"RUNNING_MODAL"} + + if not region or not rv3d: + return {"RUNNING_MODAL"} + + tool_settings.use_snap = not self.initial_snap_state if event.ctrl else self.initial_snap_state + + if tool_settings.use_snap and not self._snap_cache_built and self.active_obj: + build_snap_cache(context, self.active_obj) + self._snap_cache_built = True + + current_coord = (event.mouse_region_x, event.mouse_region_y) + + if not self._has_dragged and hasattr(self, "_start_mouse_pos"): + dx = current_coord[0] - self._start_mouse_pos[0] + dy = current_coord[1] - self._start_mouse_pos[1] + if (dx * dx + dy * dy) > (self.DRAG_THRESHOLD ** 2): + self._has_dragged = True + 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 * RAY_CAST_DISTANCE, + self.start_location, + 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: + # Snap the dimension tip (not mouse position) to target + # Calculate where the dimension tip would be with current delta + # The tip is at: gizmo_origin + axis * (init_value + delta) + # Since start_location = gizmo_origin + axis * click_offset (where user clicked), + # the tip is at: current_3d - axis * click_offset + click_offset = getattr(self, "_click_offset", 0.0) + tip_3d = current_3d - axis_direction * click_offset + + # Snap the tip position + snapped_tip = snap_to_mesh(tip_3d, context, self.active_obj, current_coord) + if snapped_tip != tip_3d: + # Adjust delta so the dimension tip lands on the snapped position + # snap_to_mesh may return a tuple from the cache, ensure it's a Vector + snapped_tip_vec = Vector(snapped_tip) if not isinstance(snapped_tip, Vector) else snapped_tip + delta = (snapped_tip_vec - self.start_location).dot(axis_direction) + click_offset + set_snap_point(snapped_tip) + else: + clear_snap_point() + else: + clear_snap_point() + + if event.shift: + delta *= PRECISION_MODE_MULTIPLIER + + if getattr(self, "invert_delta", False): + delta = -delta + + delta_scale = getattr(self, "delta_scale", 1.0) + delta *= delta_scale + + kb = self.keyboard_input + final_delta = kb.parsed_value if kb.parsed_value != 0.0 else delta + + # Use original value (before click offset) for relative dragging behavior + # This ensures no value jump - the value only changes by the drag delta + original_value = getattr(self, "_original_value", self.init_value) + + # Only update the value if user has dragged or is typing + # This prevents value jumps when just clicking without dragging + if self._has_dragged or kb.is_active: + if self.move_set_cb: + self.move_set_cb(original_value + final_delta) + self._update_header(context, original_value + final_delta, tool_settings.use_snap, event.shift) + else: + # Show original value in header until user drags + self._update_header(context, original_value, tool_settings.use_snap, event.shift) + + return {"RUNNING_MODAL"} + + # Minimum clickable length (in world units) when dimension is at 0 + MIN_CLICKABLE_LENGTH = 0.05 + + def draw_prepare(self, context: bpy.types.Context) -> None: + """Update the clickable shape to match dimension length.""" + if not hasattr(self, "_dimension_length"): + self._dimension_length = 1.0 + + # Scale the clickable shape to match the dimension length + # Use minimum length to ensure there's always a clickable area (for the arrow tip) + scale_x = max(self._dimension_length, self.MIN_CLICKABLE_LENGTH) + self.matrix_offset = Matrix.Scale(scale_x, 4, Vector((1, 0, 0))) + + def exit(self, context: bpy.types.Context, cancel: bool) -> None: + """Handle gizmo exit - restore original value if cancelled.""" + # Clear header text + if context.area: + context.area.header_text_set(None) + if hasattr(self, "keyboard_input"): + self.keyboard_input.reset() + + should_invoke_keyboard = ( + not cancel + and hasattr(self, "_has_dragged") + and not self._has_dragged + and self.move_set_cb is not None + ) + + if should_invoke_keyboard: + _gizmo_modal_context.move_set_cb = self.move_set_cb + _gizmo_modal_context.active_gizmo = self + _gizmo_modal_context.gizmo_group = getattr(self, "gizmo_group", None) + # Use click position as start_location so delta=0 at current mouse position + # This prevents value jump when modal starts + _gizmo_modal_context.start_location = self.start_location.copy() + _gizmo_modal_context.axis_direction = self.get_axis_direction() + _gizmo_modal_context.active_obj = self.active_obj + _gizmo_modal_context.delta_scale = getattr(self, "delta_scale", 1.0) + # Pass click offset so modal can snap the dimension tip (not mouse position) + _gizmo_modal_context.click_offset = getattr(self, "_click_offset", 0.0) + # Use original value (before click offset adjustment) for the input modal + original_value = getattr(self, "_original_value", self.init_value) + bpy.ops.bim.gizmo_value_input( + "INVOKE_DEFAULT", + prop_name=getattr(self, "prop_name", "Value"), + init_value=original_value, + invert_delta=getattr(self, "invert_delta", False), + ) + elif cancel and hasattr(self, "_original_value") and self.move_set_cb: + self.move_set_cb(self._original_value) + + # (keyboard input modal handles its own visibility restoration) + if not should_invoke_keyboard: + self._restore_gizmo_visibility() + + if hasattr(self, "initial_snap_state"): + context.scene.tool_settings.use_snap = self.initial_snap_state + clear_snap_point() + clear_snap_cache() + + +class BaseParametricGizmoGroup: + """Base mixin for parametric element gizmo groups (doors, windows, stairs, etc.). + + Coordinate System + ================= + + All parametric elements use IFC/Blender coordinate conventions: + + Door/Window local space (looking from interior toward exterior): + :: + + +Z (up) + | + | + |_______ +X (width) + / + / + +Y (depth, toward exterior) + + - X: Width direction (0 at left edge, positive toward right) + - Y: Depth direction (0 at interior face, positive toward exterior) + - Z: Height direction (0 at floor level, positive upward) + - Origin: Bottom-left corner at interior face + + Stair local space (viewed from above, looking down): + :: + + +Z (up) + | +Y (run/travel) + | / + | / + |/_______ +X (width) + + - X: Width direction (perpendicular to travel) + - Y: Run direction (direction of travel up the stair) + - Z: Height direction (0 at base, positive upward) + - Origin: Bottom of first riser, left edge + + Gizmo Positioning Strategy + ========================== + + Gizmos are positioned to avoid overlapping with geometry by using view-dependent + offsets. The `get_local_view_direction()` method determines which side of the + element the camera is viewing from, and gizmos are placed on the visible side. + + - Dimension gizmos: Positioned with GIZMO_OFFSET from geometry edges + - Icon gizmos: Positioned above element using ICON_Z_OFFSET, laid out horizontally + """ + + # === Gizmo Colors === + # Match Blender axis convention: X=red, Y=green, Z=blue + COLOR_RED = (1.0, 0.2, 0.2) + COLOR_GREEN = (0.1, 0.8, 0.1) + COLOR_BLUE = (0.3, 0.3, 1.0) + + # === Dimension Gizmo Layout (meters) === + ARROW_SCALE = 0.25 # Scale factor for arrow gizmos + GIZMO_OFFSET = 0.15 # Distance from geometry edge to dimension line + + # === Icon Gizmo Layout (meters) === + # Icons are positioned in a horizontal row above the element: + # [Validate] [Cancel] [Cycle] + # 0.0 0.5 0.87 <- X positions (ICON_VALIDATE_X + offset) + EDITING_ICON_SCALE = 0.2 # Scale for editing icon gizmos (validate, cancel, cycle) + ICON_VALIDATE_X = 0.0 # X position of validate (checkmark) icon + ICON_CANCEL_X = 0.5 # X offset from validate for cancel (X) icon + ICON_CYCLE_X = 0.87 # X offset from validate for cycle (arrow) icon + ICON_Z_OFFSET = 0.5 # Height above element for icons + ICON_Y_OFFSET = GIZMO_OFFSET * 2 # Y offset to keep icons clear of geometry + + dimension_gizmo_props: list[DimensionGizmoConfig] = [] + enable_editing_operator: str = "" + finish_editing_operator: str = "" + cancel_editing_operator: str = "" + cycle_type_operator: str = "" + + @classmethod + def get_color_from_name(cls, color_name: str) -> tuple[float, float, float]: + """Get color tuple from color name string.""" + colors = {"RED": cls.COLOR_RED, "GREEN": cls.COLOR_GREEN, "BLUE": cls.COLOR_BLUE} + return colors.get(color_name.upper(), cls.COLOR_RED) + + @classmethod + def get_arrow_color_from_axis(cls, axis: tuple[int, int, int]) -> tuple[float, float, float]: + 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 a rotation matrix that aligns the X-axis with the given axis direction.""" + axis_vec = Vector(axis).normalized() + default_dir = Vector((1, 0, 0)) + return default_dir.rotation_difference(axis_vec).to_matrix().to_4x4() + + @staticmethod + def get_local_view_direction(context: bpy.types.Context, world_matrix: Matrix) -> tuple[bool, bool]: + """Calculate view direction in element's local space. + + Returns: + tuple of (viewing_from_negative_y, viewing_from_negative_x) + - viewing_from_negative_y: True if camera is on the -Y side of the element + - viewing_from_negative_x: True if camera is on the -X side of the element + + Returns (False, False) if region data is unavailable. + """ + rv3d = context.region_data + if not rv3d: + return False, False + + view_direction = Vector(rv3d.view_rotation @ Vector((0, 0, -1))) + local_view_dir = world_matrix.inverted().to_3x3() @ view_direction + + viewing_from_negative_y = local_view_dir.y < 0 + viewing_from_negative_x = local_view_dir.x < 0 + + return viewing_from_negative_y, viewing_from_negative_x + + def get_y_position_for_view( + self, props, viewing_from_negative_y: bool, width_attr: str = "width", use_offset: bool = False + ) -> float: + """Get Y position based on view direction. + + Common helper for view-dependent gizmo positioning. Elements are positioned + at Y=0 or Y=width depending on which side the camera is viewing from. + + Args: + props: Element properties object + viewing_from_negative_y: True if viewing from -Y side + width_attr: Property name for element width (default "width", door/window use implicit overall_width logic) + use_offset: If True, adds/subtracts GIZMO_OFFSET from the position + + Returns: + Y position: width + offset when viewing from -Y, 0 - offset otherwise + """ + width = getattr(props, width_attr, 0) + if viewing_from_negative_y: + return width + (self.GIZMO_OFFSET if use_offset else 0) + return -self.GIZMO_OFFSET if use_offset else 0 + + def compose_gizmo_matrix(self, translation: Vector, axis: tuple[int, int, int]) -> Matrix: + """Compose a gizmo transformation matrix from translation and axis. + + This is the standard pattern used for positioning gizmos: + translation @ rotation where rotation aligns X-axis with the given axis. + + Args: + translation: Position vector for the gizmo + axis: Direction axis tuple, e.g., (1, 0, 0) for X-axis + + Returns: + Combined transformation matrix (translation @ rotation) + """ + return Matrix.Translation(translation) @ self.get_axis_rotation_matrix(axis) + + def get_lining_y_position_for_view( + self, props, viewing_from_negative_y: bool, use_offset: bool = True + ) -> float: + """Get Y position for lining-based elements (doors, windows) based on view direction. + + For elements with lining_offset property, this calculates the Y position + relative to lining_offset, flipping sides based on camera view direction. + + Args: + props: Element properties object (must have lining_offset attribute) + viewing_from_negative_y: True if viewing from -Y side + use_offset: If True, adds/subtracts GIZMO_OFFSET (default True) + + Returns: + Y position: lining_offset + GIZMO_OFFSET when viewing from -Y, + lining_offset - GIZMO_OFFSET otherwise + """ + lining_offset = getattr(props, "lining_offset", 0) + if viewing_from_negative_y: + return lining_offset + (self.GIZMO_OFFSET if use_offset else 0) + return lining_offset - (self.GIZMO_OFFSET if use_offset else 0) + + @classmethod + def is_element_type(cls, element) -> bool: + raise NotImplementedError("Subclass must implement is_element_type()") + + @classmethod + def poll(cls, context) -> bool: + 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: + raise NotImplementedError("Subclass must implement get_props()") + + def get_gizmo_prefs(self) -> Any: + raise NotImplementedError("Subclass must implement get_gizmo_prefs()") + + def is_setup_complete(self) -> bool: + """Check if gizmo setup has been completed. + + Returns True if essential gizmos have been created. This guard + prevents errors when refresh() is called before setup() completes. + + Subclasses can override to add additional checks. + """ + return hasattr(self, "validate_gizmo") + + def get_prop_min_value(self, attr_name: str) -> float: + return 0.0 + + def should_hide_gizmo(self, attr_name: str, props) -> bool: + return not props.is_editing + + def get_element_height(self, props) -> float: + return getattr(props, "overall_height", getattr(props, "height", 1.0)) + + def is_gizmo_hidden_by_modal(self, gizmo: bpy.types.Gizmo) -> bool: + """Check if a gizmo should be hidden because a modal operator is active. + + This is used to hide all gizmos except the active one during modal + operations like keyboard value input or mouse dragging. + """ + hidden_by_modal = _gizmo_modal_context.hidden_gizmos or set() + return gizmo in hidden_by_modal + + def iter_visible_dimension_gizmos(self) -> Iterator[tuple["DimensionGizmoConfig", bpy.types.Gizmo]]: + """Iterate over visible dimension gizmos with their configs. + + Yields: + Tuples of (config, gizmo) for each dimension gizmo that exists and is not hidden. + + Example: + for config, gizmo in self.iter_visible_dimension_gizmos(): + gizmo.draw_prepare(context) + """ + for config in getattr(self, "dimension_gizmo_props", []): + gizmo = getattr(self, f"dimension_{config.attr_name}_gizmo", None) + if gizmo and not gizmo.hide: + yield config, gizmo + + def get_dimension_gizmo_if_visible(self, attr_name: str) -> bpy.types.Gizmo | None: + """Get a dimension gizmo by attribute name if it exists and is visible. + + Simplifies the common pattern: + if hasattr(self, "dimension_X_gizmo") and not self.dimension_X_gizmo.hide: + to: + if gizmo := self.get_dimension_gizmo_if_visible("X"): + + Args: + attr_name: The dimension attribute name (without "dimension_" prefix and "_gizmo" suffix) + + Returns: + The gizmo if it exists and is not hidden, None otherwise. + """ + gizmo = getattr(self, f"dimension_{attr_name}_gizmo", None) + if gizmo and not gizmo.hide: + return gizmo + return None + + def get_gizmo_if_visible(self, gizmo_name: str) -> bpy.types.Gizmo | None: + """Get a gizmo by attribute name if it exists and is visible. + + Args: + gizmo_name: The full gizmo attribute name (e.g., "validate_gizmo", "lock_gizmo") + + Returns: + The gizmo if it exists and is not hidden, None otherwise. + """ + gizmo = getattr(self, gizmo_name, None) + if gizmo and not gizmo.hide: + return gizmo + return None + + def set_icon_gizmo_position( + self, + gizmo_name: str, + mw: Matrix, + x: float, + y: float, + z: float, + billboard_rot: Matrix, + scale: float = 0.5, + ) -> None: + """Set an icon gizmo's position with billboard rotation. + + Args: + gizmo_name: The gizmo attribute name (e.g., "validate_gizmo") + mw: Object's world matrix + x, y, z: Local position coordinates + billboard_rot: Billboard rotation matrix to face camera + scale: Gizmo scale factor (default 0.5) + """ + if gz := self.get_gizmo_if_visible(gizmo_name): + local_transform = Matrix.Translation(Vector((x, y, z))) @ billboard_rot @ Matrix.Scale(scale, 4) + gz.matrix_basis = mw @ local_transform + + def set_dimension_gizmo_position( + self, + attr_name: str, + mw: Matrix, + position: Vector, + axis: tuple[int, int, int], + ) -> None: + """Set a dimension gizmo's position if visible. + + Args: + attr_name: The dimension attribute name (e.g., "overall_width") + mw: Object's world matrix + position: Local position as Vector or tuple (x, y, z) + axis: Direction axis tuple (e.g., (1, 0, 0) for X) + """ + if gz := self.get_dimension_gizmo_if_visible(attr_name): + gz.matrix_basis = mw @ self.compose_gizmo_matrix(position, axis) + + def should_hide_dimension_gizmo( + self, gizmo: bpy.types.Gizmo, config: "DimensionGizmoConfig", props, gizmo_prefs + ) -> bool: + """Unified visibility check for dimension gizmos. + + Checks all hide conditions in priority order: + 1. Modal operator hiding + 2. User preference visibility toggle + 3. Editing state + 4. Custom visibility condition from config + + Args: + gizmo: The gizmo to check + config: Dimension gizmo configuration + props: Element properties object + gizmo_prefs: Gizmo visibility preferences + + Returns: + True if gizmo should be hidden, False otherwise + """ + if self.is_gizmo_hidden_by_modal(gizmo): + return True + if not getattr(gizmo_prefs, config.attr_name, True): + return True + if self.should_hide_gizmo(config.attr_name, props): + return True + if config.visibility_condition and not config.visibility_condition(props): + return True + return False + + def setup_editing_gizmos(self, context: bpy.types.Context) -> None: + prefs = tool.Blender.get_addon_preferences() + default_color = prefs.decorations_colour[:3] + highlight_color = prefs.decorator_color_selected[:3] + + 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) + + if self.cycle_type_operator: + self.cycle_gizmo = self.gizmos.new("VIEW3D_GT_cycle") + self.cycle_gizmo.use_draw_scale = False + self.cycle_gizmo.color = default_color + self.cycle_gizmo.color_highlight = highlight_color + self.cycle_gizmo.alpha = 0.8 + self.cycle_gizmo.target_set_operator(self.cycle_type_operator) + + def _make_dimension_getter(self, config: DimensionGizmoConfig): + """Create getter closure for dimension gizmo.""" + if config.compute_value: + compute_fn = config.compute_value + + def move_get(): + obj = bpy.context.active_object + if not obj: + return 0.0 + return compute_fn(self.get_props(obj)) + + return move_get + + attr_name = config.attr_name + + def move_get(): + obj = bpy.context.active_object + if not obj: + return 0.0 + return getattr(self.get_props(obj), attr_name, 0.0) + + return move_get + + def _make_dimension_setter(self, config: DimensionGizmoConfig): + """Create setter closure for dimension gizmo.""" + if config.apply_value: + apply_fn, min_val = config.apply_value, config.min_value + + def move_set(value): + obj = bpy.context.active_object + if not obj: + return + apply_fn(self.get_props(obj), max(min_val, value)) + + return move_set + + attr_name, min_val = config.attr_name, config.min_value + + def move_set(value): + obj = bpy.context.active_object + if not obj: + return + setattr(self.get_props(obj), attr_name, max(min_val, value)) + + return move_set + + def setup_dimension_gizmos(self, context: bpy.types.Context) -> None: + """Set up dimension gizmos from dimension_gizmo_props configuration.""" + prefs = tool.Blender.get_addon_preferences() + highlight_color = prefs.decorator_color_selected[:3] + + for config in getattr(self, "dimension_gizmo_props", []): + gizmo = self.gizmos.new("BIM_GT_gizmo_dimension") + gizmo.move_get_cb = self._make_dimension_getter(config) + gizmo.move_set_cb = self._make_dimension_setter(config) + gizmo.axis = Vector(config.axis) + gizmo.local_axis = Vector(config.axis) + gizmo.invert_delta = config.invert_delta + gizmo.delta_scale = config.delta_scale + gizmo.prop_name = config.prop_name # Auto-derived in __post_init__ + gizmo.gizmo_group = self + gizmo.color = self.get_color_from_name(config.color) + gizmo.color_highlight = highlight_color + gizmo.alpha = 1.0 + gizmo.use_draw_modal = True + gizmo.use_draw_scale = False + gizmo.text_offset_sign = config.text_offset_sign + gizmo.text_alignment = config.text_alignment + gizmo.show_start_arrow = config.show_start_arrow + gizmo.show_end_arrow = config.show_end_arrow + setattr(self, f"dimension_{config.attr_name}_gizmo", gizmo) + + def update_dimension_gizmos(self, mw: Matrix, props) -> None: + """Update dimension gizmos from dimension_gizmo_props configuration.""" + gizmo_prefs = self.get_gizmo_prefs() + + for config in getattr(self, "dimension_gizmo_props", []): + gizmo = getattr(self, f"dimension_{config.attr_name}_gizmo", None) + if gizmo is None: + continue + + # Use unified visibility checker + if self.should_hide_dimension_gizmo(gizmo, config, props, gizmo_prefs): + gizmo.hide = True + continue + + gizmo.hide = False + + matrix_method = getattr(self, f"get_dimension_matrix_{config.attr_name}", None) + base_matrix = matrix_method(props) if matrix_method else Matrix.Identity(4) + + if config.compute_value: + value = config.compute_value(props) + else: + value = getattr(props, config.attr_name, 0.0) + + # Handle negative values by flipping the gizmo direction + if value < 0: + # Flip the X axis (dimension direction) for negative values + flip_matrix = Matrix.Scale(-1, 4, Vector((1, 0, 0))) + gizmo.matrix_basis = mw @ base_matrix @ flip_matrix + gizmo.set_dimension_length(abs(value)) + else: + gizmo.matrix_basis = mw @ base_matrix + gizmo.set_dimension_length(value) + + def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: + """Get Y offset for icons based on view direction. + + Returns negative offset when viewing from -Y side (icons move to -Y), + positive offset when viewing from +Y side (icons move to +Y). + Subclasses can override to customize behavior. + """ + return 0.0 + + def update_editing_gizmos(self, context: bpy.types.Context, mw: Matrix, props) -> None: + """Update editing icon gizmo positions to billboard toward camera.""" + icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET + icon_y = self.get_icon_y_offset(context, mw) + billboard_rot = get_billboard_rotation(context) + + # This ensures icons face camera regardless of object rotation + local_pos_validate = Vector((self.ICON_VALIDATE_X, icon_y, icon_z)) + world_pos_validate = mw @ local_pos_validate + + icon_matrix_base = Matrix.Translation(world_pos_validate) @ billboard_rot @ Matrix.Scale(0.5, 4) + + if props.is_editing: + self.pen_gizmo.hide = True + self.validate_gizmo.hide = self.is_gizmo_hidden_by_modal(self.validate_gizmo) + self.validate_gizmo.matrix_basis = icon_matrix_base + + self.cancel_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cancel_gizmo) + local_pos_cancel = Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z)) + world_pos_cancel = mw @ local_pos_cancel + self.cancel_gizmo.matrix_basis = Matrix.Translation(world_pos_cancel) @ billboard_rot @ Matrix.Scale(0.5, 4) + + if self.cycle_type_operator: + self.cycle_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cycle_gizmo) + local_pos_cycle = Vector((self.ICON_VALIDATE_X + self.ICON_CYCLE_X, icon_y, icon_z)) + world_pos_cycle = mw @ local_pos_cycle + self.cycle_gizmo.matrix_basis = Matrix.Translation(world_pos_cycle) @ billboard_rot @ Matrix.Scale(0.30, 4) + else: + self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo) + self.pen_gizmo.matrix_basis = icon_matrix_base + self.validate_gizmo.hide = True + self.cancel_gizmo.hide = True + if self.cycle_type_operator: + self.cycle_gizmo.hide = True + + def draw_prepare(self, context: bpy.types.Context) -> None: + """Called before drawing - updates gizmos to face camera. + + This method updates editing gizmos and dimension gizmos. + Subclasses can override _update_dimension_gizmo_positions() to customize + dimension gizmo positioning based on view direction. + """ + obj = context.active_object + if not obj: + return + props = self.get_props(obj) + mw = obj.matrix_world + self.update_editing_gizmos(context, mw, props) + + self._update_dimension_gizmo_positions(context, mw, props) + + # Prepare dimension gizmos for drawing + for _, gizmo in self.iter_visible_dimension_gizmos(): + gizmo.draw_prepare(context) + + def _update_dimension_gizmo_positions( + self, context: bpy.types.Context, mw: "Matrix", props # noqa: ARG002 + ) -> None: + """Update dimension gizmo positions based on view direction. + + Override this method in subclasses to implement view-dependent + positioning for dimension gizmos. + + Args: + context: Blender context + mw: Object's world matrix + props: Element properties object + """ diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 5629b1a3cc..bf2c401f16 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -162,7 +162,9 @@ classes = ( stair.EnableEditingStair, stair.RemoveStair, stair.ToggleStairTotalLengthLock, + stair.ToggleStairCustomTreadLock, stair.AdjustStairTreads, + stair.SetStairTreads, stair.CycleStairType, stair.GizmoStairEdition, sverchok_modifier.CreateNewSverchokGraph, diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index 95280b62ca..6705b59570 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -33,8 +33,8 @@ 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 +from bonsai.bim.module.drawing import gizmos as gizmo +from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig from mathutils import Vector, Matrix @@ -786,31 +786,33 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): cancel_editing_operator = "bim.cancel_editing_door" cycle_type_operator = "bim.cycle_door_type" - gizmo_props = [ - 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)), + dimension_gizmo_props = [ + DimensionGizmoConfig(attr_name="overall_width", axis=(1, 0, 0), min_value=0.01, text_offset_sign=-1), + DimensionGizmoConfig(attr_name="overall_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start"), + DimensionGizmoConfig(attr_name="threshold_thickness", axis=(0, 0, 1)), + DimensionGizmoConfig(attr_name="threshold_depth", axis=(0, 1, 0)), + DimensionGizmoConfig(attr_name="threshold_offset", axis=(0, 1, 0)), + DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0)), + DimensionGizmoConfig(attr_name="lining_depth", axis=(0, 1, 0)), + DimensionGizmoConfig(attr_name="lining_thickness", axis=(-1, 0, 0)), + DimensionGizmoConfig(attr_name="transom_offset", axis=(0, 0, 1)), + DimensionGizmoConfig(attr_name="transom_thickness", axis=(0, 0, 1)), + DimensionGizmoConfig(attr_name="casing_thickness", axis=(-1, 0, 0)), + DimensionGizmoConfig(attr_name="casing_depth", axis=(0, 1, 0)), ] @classmethod def is_element_type(cls, element) -> bool: return tool.Blender.Modifier.is_door(element) - def get_props(self, obj): + def get_props(self, obj: bpy.types.Object): return tool.Model.get_door_props(obj) def get_gizmo_prefs(self): prefs = tool.Blender.get_addon_preferences() return prefs.gizmos.door - def should_hide_gizmo(self, attr_name, props): + def should_hide_gizmo(self, attr_name: str, props) -> bool: """Door-specific visibility rules for gizmos.""" if not props.is_editing: return True @@ -824,90 +826,100 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): 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_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: + """Get Y offset for icons based on view direction. - 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_threshold_thickness(self, props): - translation = Matrix.Translation( - V_( - props.overall_width / 2, - props.threshold_offset + props.threshold_depth / 2 + props.lining_offset, - props.threshold_thickness, + Positions icons further than the furthest geometry: + max(threshold_offset + threshold_depth, lining_offset + lining_depth) + 2 * GIZMO_OFFSET + """ + obj = context.active_object + if not obj: + return self.ICON_Y_OFFSET + props = self.get_props(obj) + furthest_y = ( + max( + props.threshold_offset + props.threshold_depth, + props.lining_offset + props.lining_depth, ) + + 2 * self.GIZMO_OFFSET ) - 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.lining_offset, props.threshold_thickness / 2) - ) - rotation = self.get_axis_rotation_matrix((0, 1, 0)) - return translation @ rotation + viewing_from_negative_y, _ = self.get_local_view_direction(context, mw) + if viewing_from_negative_y: + return -furthest_y + return furthest_y - 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) + def get_dimension_matrix_threshold_thickness(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(props.overall_width / 2, props.threshold_offset + props.threshold_depth, 0), (0, 0, 1) ) - 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.overall_width - props.lining_thickness, props.lining_depth / 2, props.overall_height / 2) + def get_dimension_matrix_threshold_depth(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(props.overall_width / 2, props.threshold_offset, props.threshold_thickness), (0, 1, 0) ) - rotation = self.get_axis_rotation_matrix((-1, 0, 0)) - return translation @ rotation - def get_gizmo_matrix_transom_offset(self, props): - # Position at the bottom of the transom (transom extends upward from offset) - translation = Matrix.Translation( - V_(props.overall_width / 2, props.lining_offset, props.transom_offset) + def get_dimension_matrix_threshold_offset(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(props.overall_width / 2 - 0.1, 0, props.threshold_thickness), (0, 1, 0) ) - rotation = self.get_axis_rotation_matrix((0, 0, 1)) - return translation @ rotation - def get_gizmo_matrix_transom_thickness(self, props): - # Position at the top of the transom (offset + thickness) - translation = Matrix.Translation( - V_(props.overall_width / 2, props.lining_offset, props.transom_offset + props.transom_thickness) + def get_dimension_matrix_lining_offset(self, props) -> Matrix: + return self.compose_gizmo_matrix(V_(0, 0, 0), (0, 1, 0)) + + def get_dimension_matrix_lining_depth(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(props.overall_width, props.lining_offset, props.overall_height), (0, 1, 0) + ) + + def get_dimension_matrix_lining_thickness(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(props.overall_width, props.lining_depth / 2, props.overall_height / 2), (-1, 0, 0) + ) + + def get_dimension_matrix_transom_offset(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(props.overall_width / 2, props.lining_offset, 0), (0, 0, 1) + ) + + def get_dimension_matrix_transom_thickness(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(props.overall_width / 2, props.lining_offset, props.transom_offset), (0, 0, 1) ) - 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 + x = 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): + def get_dimension_matrix_casing_thickness(self, props) -> Matrix: 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 + return self.compose_gizmo_matrix(V_(x, y_base + props.casing_depth / 2, z), (-1, 0, 0)) - def get_gizmo_matrix_casing_depth(self, props): + def get_dimension_matrix_casing_depth(self, props) -> Matrix: 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 + return self.compose_gizmo_matrix(V_(x - props.casing_thickness, y_base, z), (0, 1, 0)) - def setup(self, context): - # Use base class methods for common gizmos - self.setup_property_gizmos(context) + def get_dimension_matrix_overall_width(self, props) -> Matrix: + """Position width dimension below the door.""" + return self.compose_gizmo_matrix( + V_(0, props.lining_offset - self.GIZMO_OFFSET, -self.GIZMO_OFFSET), (1, 0, 0) + ) + + def get_dimension_matrix_overall_height(self, props) -> Matrix: + """Position height dimension to the side of the door.""" + casing_offset = props.casing_thickness if props.lining_offset == 0.0 else 0.0 + return self.compose_gizmo_matrix( + V_(props.overall_width + casing_offset + self.GIZMO_OFFSET, props.lining_offset - self.GIZMO_OFFSET, 0), + (0, 0, 1), + ) + + def setup(self, context: bpy.types.Context) -> None: self.setup_editing_gizmos(context) - - # Door-specific: swing arc gizmos + self.setup_dimension_gizmos(context) prefs = tool.Blender.get_addon_preferences() highlight_color = prefs.decorator_color_selected[:3] inactive_color = prefs.decorator_color_background[:3] @@ -922,7 +934,6 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): 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 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 @@ -933,40 +944,65 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): op.flip_geometry = True op.flip_local_axes = "XY" - def refresh(self, context): + def refresh(self, context: bpy.types.Context) -> None: + if not self.is_setup_complete(): + return obj = context.active_object if not obj: return props = self.get_props(obj) mw = obj.matrix_world - self.update_property_gizmos(mw, props) self.update_swing_gizmos(mw, props) self.update_editing_gizmos(context, mw, props) + self.update_dimension_gizmos(mw, props) - def update_swing_gizmos(self, mw, props): + def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props) -> None: + """Update dimension gizmo positions based on camera view direction.""" + viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw) + y_pos = self.get_lining_y_position_for_view(props, viewing_from_negative_y) + + self.set_dimension_gizmo_position("overall_width", mw, V_(0, y_pos, -self.GIZMO_OFFSET), (1, 0, 0)) + + casing_offset = props.casing_thickness if props.lining_offset == 0.0 else 0.0 + if viewing_from_negative_x: + x_pos = -casing_offset - self.GIZMO_OFFSET + else: + x_pos = props.overall_width + casing_offset + self.GIZMO_OFFSET + self.set_dimension_gizmo_position("overall_height", mw, V_(x_pos, y_pos, 0), (0, 0, 1)) + + def update_swing_gizmos(self, mw: Matrix, props) -> None: """Update swing gizmo position and color based on editing state.""" - prefs = tool.Blender.get_addon_preferences() - door_gizmo_prefs = prefs.gizmos.door + door_type_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.gizmo_door_type) + flip_arc_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.gizmo_flip_arc) - 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 door_type_hidden_by_modal: + self.gizmo_door_type.hide = True + else: + prefs = tool.Blender.get_addon_preferences() + door_gizmo_prefs = prefs.gizmos.door + self.gizmo_door_type.hide = not props.is_editing or not door_gizmo_prefs.swing_arc + + if flip_arc_hidden_by_modal: + self.gizmo_flip_arc.hide = True + else: + prefs = tool.Blender.get_addon_preferences() + door_gizmo_prefs = prefs.gizmos.door + self.gizmo_flip_arc.hide = not props.is_editing or not door_gizmo_prefs.flip_arc - # If 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, props.lining_offset, 0)) @ Matrix.Scale( props.overall_width, 4 ) + prefs = tool.Blender.get_addon_preferences() 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 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/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index 8ced98b0d1..4611c1b0a0 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -27,9 +27,12 @@ import ifcopenshell.util.representation import ifcopenshell.util.unit import bonsai.core.root import bonsai.tool as tool -from bonsai.bim import gizmo -from bonsai.bim.gizmo import GizmoPropConfig +from bonsai.bim.module.drawing import gizmos as gizmo +from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig +from bonsai.tool.numeric_input import IntegerInputState, run_integer_input_modal, update_header from mathutils import Vector, Matrix + +V_ = tool.Blender.V_ from bmesh.types import BMVert from bpy.types import Operator from bpy.props import FloatProperty, IntProperty @@ -218,6 +221,7 @@ class AddStair(bpy.types.Operator, tool.Ifc.Operator): class CancelEditingStair(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.cancel_editing_stair" bl_label = "Cancel Editing Stair" + bl_description = "Cancel editing and revert stair parameters to their previous values" bl_options = {"REGISTER"} def _execute(self, context): @@ -239,6 +243,7 @@ class CancelEditingStair(bpy.types.Operator, tool.Ifc.Operator): class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.finish_editing_stair" bl_label = "Finish Editing Stair" + bl_description = "Apply changes and finish editing stair parameters" bl_options = {"REGISTER"} def _execute(self, context): @@ -266,6 +271,7 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator): class EnableEditingStair(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_stair" bl_label = "Enable Editing Stair" + bl_description = "Enter edit mode to modify stair parameters interactively" bl_options = {"REGISTER"} def _execute(self, context): @@ -317,8 +323,26 @@ class ToggleStairTotalLengthLock(bpy.types.Operator): return {"FINISHED"} +class ToggleStairCustomTreadLock(bpy.types.Operator): + """Toggle custom first/last tread runs. When unlocked, first and last treads can have different lengths.""" + + bl_idname = "bim.toggle_stair_custom_tread_lock" + bl_label = "Toggle Custom Tread Lock" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + + props = tool.Model.get_stair_props(obj) + props.custom_tread_lock = not props.custom_tread_lock + + return {"FINISHED"} + + class AdjustStairTreads(bpy.types.Operator): - """Adjust the number of treads""" + """Adjust the number of treads. Shift+click to enter a specific number.""" bl_idname = "bim.adjust_stair_treads" bl_label = "Adjust Stair Treads" @@ -326,6 +350,12 @@ class AdjustStairTreads(bpy.types.Operator): increment: IntProperty(name="Increment", default=1) + def invoke(self, context, event): + if event.shift: + bpy.ops.bim.set_stair_treads("INVOKE_DEFAULT") + return {"FINISHED"} + return self.execute(context) + def execute(self, context): obj = context.active_object if not obj: @@ -339,6 +369,51 @@ class AdjustStairTreads(bpy.types.Operator): return {"FINISHED"} +class SetStairTreads(bpy.types.Operator): + """Set the number of treads to a specific value.""" + + bl_idname = "bim.set_stair_treads" + bl_label = "Set Number of Treads" + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def invoke(self, context, event): # noqa: ARG002 + obj = context.active_object + if not obj: + return {"CANCELLED"} + + props = tool.Model.get_stair_props(obj) + self._input = IntegerInputState.from_value(props.number_of_treads, min_value=1) + self._original_value = props.number_of_treads + + bpy.ops.ed.undo_push(message="Set Number of Treads") + context.window_manager.modal_handler_add(self) + update_header(context, self._format_header()) + return {"RUNNING_MODAL"} + + def modal(self, context, event): + return run_integer_input_modal(self, context, event) + + def _apply_value(self, context) -> None: + obj = context.active_object + if not obj: + return + value = self._input.get_value() + if value is not None: + props = tool.Model.get_stair_props(obj) + props.number_of_treads = value + + def _restore_value(self, context) -> None: + obj = context.active_object + if obj: + props = tool.Model.get_stair_props(obj) + props.number_of_treads = self._original_value + + def _format_header(self) -> str: + input_str = self._input.get_input_string() + validity = "" if self._input.is_valid else " [must be >= 1]" + return f"Number of Treads: {input_str}_{validity} | Enter to confirm, Esc to cancel" + + class CycleStairType(bpy.types.Operator): """Cycle through stair types. Shift+click to cycle in reverse.""" @@ -366,6 +441,26 @@ class CycleStairType(bpy.types.Operator): return {"FINISHED"} +def _compute_first_tread_run(props) -> float: + """Get the first custom tread run value from the tuple property.""" + return props.custom_first_last_tread_run[0] + + +def _apply_first_tread_run(props, value: float) -> None: + """Apply a new first custom tread run value, preserving the second value.""" + props.custom_first_last_tread_run = (max(0.01, value), props.custom_first_last_tread_run[1]) + + +def _compute_last_tread_run(props) -> float: + """Get the last custom tread run value from the tuple property.""" + return props.custom_first_last_tread_run[1] + + +def _apply_last_tread_run(props, value: float) -> None: + """Apply a new last custom tread run value, preserving the first value.""" + props.custom_first_last_tread_run = (props.custom_first_last_tread_run[0], max(0.01, value)) + + class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): bl_idname = "OBJECT_GGT_bim_stair_edition" bl_label = "Stair Editing Gizmo" @@ -373,171 +468,197 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): bl_region_type = "WINDOW" bl_options = {"3D", "PERSISTENT"} - # Gizmo layout offsets (meters) - GIZMO_X_OFFSET = 0.5 # Horizontal offset from stair end - GIZMO_PLUS_X_OFFSET = 0.25 # Additional X offset for plus button - GIZMO_MINUS_X_OFFSET = 0.5 # Additional X offset for minus button - GIZMO_BUTTON_Z_OFFSET = 0.15 # Vertical offset for +/- buttons above lock - GIZMO_CYCLE_Z_OFFSET = 0.5 # Vertical offset for cycle gizmo above lock + # === Stair-Specific Icon Layout (meters) === + # Additional icons for stair editing, positioned after standard icons: + # [Validate] [Cancel] [Cycle] [TreadLock] [Plus] [Minus] + ICON_TREAD_LOCK_X = 1.24 # X position for tread lock toggle icon + ICON_PLUS_X = 1.61 # X position for add tread (+) icon + ICON_MINUS_X = 1.98 # X position for remove tread (-) icon + ICON_PLUS_MINUS_SCALE = 0.24 # Scale for plus/minus icons (slightly larger) enable_editing_operator = "bim.enable_editing_stair" finish_editing_operator = "bim.finish_editing_stair" cancel_editing_operator = "bim.cancel_editing_stair" + cycle_type_operator = "bim.cycle_stair_type" - 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)), + def get_icon_y_offset(self, context, mw): + """Get Y offset for icons based on view direction. + + Positions icons further than the furthest geometry: + stair_width + 2 * GIZMO_OFFSET + """ + obj = context.active_object + if not obj: + return self.ICON_Y_OFFSET + props = self.get_props(obj) + furthest_y = props.width + 2 * self.GIZMO_OFFSET + + viewing_from_negative_y, _ = self.get_local_view_direction(context, mw) + if viewing_from_negative_y: + return -furthest_y + return furthest_y + + dimension_gizmo_props = [ + DimensionGizmoConfig( + attr_name="total_length_target", + axis=(1, 0, 0), + prop_name="Total Length", + min_value=0.01, + text_offset_sign=-1, + ), + DimensionGizmoConfig(attr_name="height", axis=(0, 0, 1), min_value=0.01, text_alignment="start"), + DimensionGizmoConfig(attr_name="width", axis=(0, 1, 0), min_value=0.01), + DimensionGizmoConfig( + attr_name="tread_run", + axis=(1, 0, 0), + min_value=0.01, + visibility_condition=lambda props: props.custom_tread_lock or props.number_of_treads > 2, + ), + DimensionGizmoConfig( + attr_name="custom_first_tread_run", + axis=(1, 0, 0), + prop_name="First Tread", + min_value=0.01, + visibility_condition=lambda props: not props.custom_tread_lock, + compute_value=_compute_first_tread_run, + apply_value=_apply_first_tread_run, + ), + DimensionGizmoConfig( + attr_name="custom_last_tread_run", + axis=(1, 0, 0), + prop_name="Last Tread", + min_value=0.01, + visibility_condition=lambda props: not props.custom_tread_lock, + compute_value=_compute_last_tread_run, + apply_value=_apply_last_tread_run, + ), + DimensionGizmoConfig(attr_name="nosing_length", axis=(-1, 0, 0)), + DimensionGizmoConfig( + attr_name="tread_depth", + axis=(0, 0, -1), + visibility_condition=lambda props: props.stair_type != "GENERIC", + ), + DimensionGizmoConfig( + attr_name="riser_height", + axis=(0, 0, 1), + min_value=0.01, + text_alignment="start", + compute_value=lambda props: props.height / (props.number_of_treads + 1), + apply_value=lambda props, value: setattr(props, "height", max(0.01, value) * (props.number_of_treads + 1)), + ), + DimensionGizmoConfig( + attr_name="nosing_depth", + axis=(0, 0, -1), + visibility_condition=lambda props: props.nosing_length != 0.0 and props.stair_type != "WOOD/STEEL", + ), + DimensionGizmoConfig( + attr_name="base_slab_depth", + axis=(0, 0, -1), + visibility_condition=lambda props: props.stair_type == "CONCRETE", + ), + DimensionGizmoConfig( + attr_name="top_slab_depth", + axis=(0, 0, -1), + visibility_condition=lambda props: props.stair_type == "CONCRETE", + ), ] @classmethod def is_element_type(cls, element) -> bool: return tool.Blender.Modifier.is_stair(element) - def get_props(self, obj): + def get_props(self, obj: bpy.types.Object): 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 + """Calculate the total horizontal run of the stair. + + Takes into account custom first/last tread runs when custom_tread_lock is False. + """ + number_of_rises = props.number_of_treads + 1 + total_run = 0.0 + default_rises = number_of_rises + + if not props.custom_tread_lock: + if props.custom_first_last_tread_run[0] is not None: # May be 0 though + default_rises -= 1 + total_run += props.custom_first_last_tread_run[0] + if props.custom_first_last_tread_run[1] is not None: # May be 0 though + default_rises -= 1 + total_run += props.custom_first_last_tread_run[1] + + total_run += props.tread_run * default_rises + return total_run @staticmethod def _get_first_riser_height(props) -> float: """Calculate the height of the first riser.""" return props.height / (props.number_of_treads + 1) - def get_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_dimension_matrix_total_length_target(self, props) -> Matrix: + return self.compose_gizmo_matrix(V_(0, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET), (1, 0, 0)) - def get_gizmo_matrix_width_top(self, props): - """Position for the secondary width gizmo at the top of the stairs.""" + def get_dimension_matrix_height(self, props) -> Matrix: total_run = self._get_stair_total_run(props) - translation = Matrix.Translation(Vector((total_run, props.width, props.height))) - rotation = self.get_axis_rotation_matrix((0, 1, 0)) - return translation @ rotation + return self.compose_gizmo_matrix(V_(total_run + self.GIZMO_OFFSET, -self.GIZMO_OFFSET, 0), (0, 0, 1)) - def get_gizmo_matrix_height(self, props): + def get_dimension_matrix_width(self, props) -> Matrix: + return self.compose_gizmo_matrix(V_(self.GIZMO_OFFSET, 0, -self.GIZMO_OFFSET), (0, 1, 0)) + + def get_dimension_matrix_tread_run(self, props) -> Matrix: + """Position depends on custom_tread_lock state.""" + riser_height = self._get_first_riser_height(props) + if props.custom_tread_lock: + x_offset = 0 + z_offset = riser_height + else: + x_offset = props.custom_first_last_tread_run[0] + z_offset = riser_height * 2 + return self.compose_gizmo_matrix(V_(x_offset, 0, z_offset), (1, 0, 0)) + + def get_dimension_matrix_custom_first_tread_run(self, props) -> Matrix: + riser_height = self._get_first_riser_height(props) + return self.compose_gizmo_matrix(V_(0, 0, riser_height), (1, 0, 0)) + + def get_dimension_matrix_custom_last_tread_run(self, props) -> Matrix: total_run = self._get_stair_total_run(props) - translation = Matrix.Translation(Vector((total_run, props.width / 2, props.height))) - rotation = self.get_axis_rotation_matrix((0, 0, 1)) - return translation @ rotation + x_offset = total_run - props.custom_first_last_tread_run[1] + return self.compose_gizmo_matrix(V_(x_offset, 0, props.height), (1, 0, 0)) - def get_gizmo_matrix_tread_run(self, props): + def get_dimension_matrix_nosing_length(self, props) -> Matrix: 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 + return self.compose_gizmo_matrix(V_(0, props.width / 2, riser_height), (-1, 0, 0)) - def get_gizmo_matrix_tread_depth(self, props): + def get_dimension_matrix_tread_depth(self, props) -> Matrix: 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 + return self.compose_gizmo_matrix(V_(0, 0, riser_height), (0, 0, -1)) - def get_gizmo_matrix_nosing_length(self, props): + def get_dimension_matrix_riser_height(self, props) -> Matrix: + return self.compose_gizmo_matrix(V_(props.tread_run, props.width, 0), (0, 0, 1)) + + def get_dimension_matrix_nosing_depth(self, props) -> Matrix: riser_height = self._get_first_riser_height(props) - translation = Matrix.Translation(Vector((-props.nosing_length, props.width / 2, riser_height))) - rotation = self.get_axis_rotation_matrix((-1, 0, 0)) - return translation @ rotation + return self.compose_gizmo_matrix(V_(-props.nosing_length, props.width / 2, riser_height), (0, 0, -1)) - 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_dimension_matrix_base_slab_depth(self, props) -> Matrix: + return self.compose_gizmo_matrix(V_(0, props.width / 2, 0), (0, 0, -1)) - 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): + def get_dimension_matrix_top_slab_depth(self, props) -> Matrix: 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 + return self.compose_gizmo_matrix(V_(total_run, props.width / 2, props.height), (0, 0, -1)) - def setup(self, context): - self.setup_property_gizmos(context) + def setup(self, context: bpy.types.Context) -> None: self.setup_editing_gizmos(context) + self.setup_dimension_gizmos(context) - # Stair-specific gizmos prefs = tool.Blender.get_addon_preferences() highlight_color = prefs.decorator_color_selected[:3] - # Secondary width gizmo at the top of the stairs (linked to same width property) - self.gizmo_width_top = self.gizmos.new("BIM_GT_gizmo_arrow_2d") - - def make_width_get(): - def move_get(): - obj = bpy.context.active_object - if not obj: - return 0.0 - props = self.get_props(obj) - return props.width - return move_get - - def make_width_set(): - def move_set(value): - obj = bpy.context.active_object - if not obj: - return - props = self.get_props(obj) - props.width = max(0.0, value) - return move_set - - self.gizmo_width_top.move_get_cb = make_width_get() - self.gizmo_width_top.move_set_cb = make_width_set() - self.gizmo_width_top.axis = Vector((0, 1, 0)) - self.gizmo_width_top.local_axis = Vector((0, 1, 0)) - self.gizmo_width_top.invert_delta = False - self.gizmo_width_top.delta_scale = 1.0 - self.gizmo_width_top.prop_name = "Width" - self.gizmo_width_top.gizmo_group = self - self.gizmo_width_top.color = self.COLOR_GREEN - self.gizmo_width_top.color_highlight = highlight_color - self.gizmo_width_top.alpha = 0.99 - self.gizmo_width_top.use_draw_modal = True - self.gizmo_width_top.use_draw_scale = True - - # 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 @@ -546,7 +667,14 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): 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.tread_lock_gizmo = self.gizmos.new("VIEW3D_GT_lock") + self.tread_lock_gizmo.use_draw_scale = False + self.tread_lock_gizmo.color = (1.0, 1.0, 1.0) + self.tread_lock_gizmo.color_highlight = highlight_color + self.tread_lock_gizmo.alpha = 0.8 + self.tread_lock_gizmo.prop_path = "BIMStairProperties.custom_tread_lock" + self.tread_lock_gizmo.target_set_operator("bim.toggle_stair_custom_tread_lock") + self.plus_gizmo = self.gizmos.new("VIEW3D_GT_plus") self.plus_gizmo.use_draw_scale = False self.plus_gizmo.color = self.COLOR_GREEN @@ -555,7 +683,6 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): 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 @@ -564,16 +691,9 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): 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): + def refresh(self, context: bpy.types.Context) -> None: + if not self.is_setup_complete(): + return obj = context.active_object if not obj: return @@ -581,26 +701,17 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): props = self.get_props(obj) mw = obj.matrix_world billboard_rot = gizmo.get_billboard_rotation(context) - self.update_property_gizmos(mw, props) self.update_editing_gizmos(context, mw, props) - self.update_width_top_gizmo(mw, props) self.update_lock_gizmo(mw, props, billboard_rot) - self.update_tread_count_gizmos(mw, props, billboard_rot) - self.update_cycle_gizmo(mw, props, billboard_rot) + self.update_tread_lock_gizmo(props) + self.update_tread_count_gizmos(props) + self.update_dimension_gizmos(mw, props) - def update_width_top_gizmo(self, mw, props): - """Update the secondary width gizmo at the top of the stairs.""" - gizmo_prefs = self.get_gizmo_prefs() - # Use same visibility as the main width gizmo - self.gizmo_width_top.hide = not props.is_editing or not getattr(gizmo_prefs, "width", True) - - if self.gizmo_width_top.hide: + def update_lock_gizmo(self, mw: Matrix, props, billboard_rot: Matrix) -> None: + if self.is_gizmo_hidden_by_modal(self.lock_gizmo): + self.lock_gizmo.hide = True return - self.gizmo_width_top.matrix_basis = mw @ self.get_gizmo_matrix_width_top(props) - self.gizmo_width_top.matrix_offset = Matrix.Scale(self.ARROW_SCALE, 4) - - def update_lock_gizmo(self, mw, props, billboard_rot): gizmo_prefs = self.get_gizmo_prefs() self.lock_gizmo.hide = not props.is_editing or not gizmo_prefs.lock @@ -610,75 +721,146 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): 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 + self.GIZMO_X_OFFSET local_transform = ( - Matrix.Translation(Vector((lock_x, props.width / 2, props.height))) + Matrix.Translation(Vector((total_run + 0.5, -self.GIZMO_OFFSET, -self.GIZMO_OFFSET))) @ billboard_rot - @ Matrix.Scale(0.2, 4) + @ Matrix.Scale(self.EDITING_ICON_SCALE, 4) ) self.lock_gizmo.matrix_basis = mw @ local_transform - def update_tread_count_gizmos(self, mw, props, billboard_rot): - gizmo_prefs = self.get_gizmo_prefs() - - self.plus_gizmo.hide = not props.is_editing or not gizmo_prefs.plus - 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: + def update_tread_lock_gizmo(self, props) -> None: + """Update visibility of tread lock gizmo. Positioning is handled in _update_editing_icon_positions.""" + if not hasattr(self, "tread_lock_gizmo"): return - total_run = self._get_stair_total_run(props) - base_x = total_run + props.tread_run + self.GIZMO_X_OFFSET - - if not self.plus_gizmo.hide: - plus_local_transform = ( - Matrix.Translation(Vector(( - base_x + self.GIZMO_PLUS_X_OFFSET, - props.width / 2, - props.height + self.GIZMO_BUTTON_Z_OFFSET - ))) - @ billboard_rot - @ 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 + self.GIZMO_MINUS_X_OFFSET, - props.width / 2, - props.height + self.GIZMO_BUTTON_Z_OFFSET - ))) - @ billboard_rot - @ Matrix.Scale(0.2, 4) - ) - self.minus_gizmo.matrix_basis = mw @ minus_local_transform - - def update_cycle_gizmo(self, mw, props, billboard_rot): - gizmo_prefs = self.get_gizmo_prefs() - self.cycle_gizmo.hide = not props.is_editing or not gizmo_prefs.cycle - - if self.cycle_gizmo.hide: + if self.is_gizmo_hidden_by_modal(self.tread_lock_gizmo): + self.tread_lock_gizmo.hide = True return + gizmo_prefs = self.get_gizmo_prefs() + self.tread_lock_gizmo.hide = not props.is_editing or not gizmo_prefs.lock + + def update_tread_count_gizmos(self, props) -> None: + """Update visibility of +/- tread count gizmos. Positioning is handled in _update_editing_icon_positions.""" + if not hasattr(self, "plus_gizmo") or not hasattr(self, "minus_gizmo"): + return + + plus_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.plus_gizmo) + minus_hidden_by_modal = self.is_gizmo_hidden_by_modal(self.minus_gizmo) + + if plus_hidden_by_modal: + self.plus_gizmo.hide = True + else: + gizmo_prefs = self.get_gizmo_prefs() + self.plus_gizmo.hide = not props.is_editing or not gizmo_prefs.plus + + if minus_hidden_by_modal: + self.minus_gizmo.hide = True + else: + gizmo_prefs = self.get_gizmo_prefs() + self.minus_gizmo.hide = not props.is_editing or props.number_of_treads <= 1 or not gizmo_prefs.minus + + def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props) -> None: + """Update dimension gizmo positions based on camera view direction.""" + viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw) + billboard_rot = gizmo.get_billboard_rotation(context) total_run = self._get_stair_total_run(props) - cycle_x = total_run + props.tread_run + self.GIZMO_X_OFFSET - local_transform = ( - Matrix.Translation(Vector(( - cycle_x, - props.width / 2, - props.height + self.GIZMO_CYCLE_Z_OFFSET - ))) - @ billboard_rot - @ Matrix.Scale(0.2, 4) + riser_height = self._get_first_riser_height(props) + + self._update_overall_dimension_gizmos(mw, props, viewing_from_negative_y, viewing_from_negative_x, total_run) + self._update_tread_dimension_gizmos(mw, props, viewing_from_negative_y, total_run, riser_height) + self._update_detail_dimension_gizmos(mw, props, viewing_from_negative_y, riser_height) + self._update_lock_gizmo_position(mw, props, viewing_from_negative_y, billboard_rot, total_run) + self._update_editing_icon_positions(mw, props, viewing_from_negative_y, billboard_rot) + + def _update_overall_dimension_gizmos( + self, mw: Matrix, props, viewing_from_negative_y: bool, viewing_from_negative_x: bool, total_run: float + ) -> None: + """Update overall dimension gizmos (total_length, width, height).""" + if gizmo := self.get_dimension_gizmo_if_visible("total_length_target"): + y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True) + gizmo.matrix_basis = mw @ self.compose_gizmo_matrix( + V_(0, y_pos, -self.GIZMO_OFFSET), (1, 0, 0) + ) + + if gizmo := self.get_dimension_gizmo_if_visible("width"): + x_pos = total_run + self.GIZMO_OFFSET if viewing_from_negative_x else -self.GIZMO_OFFSET + gizmo.matrix_basis = mw @ self.compose_gizmo_matrix( + V_(x_pos, 0, -self.GIZMO_OFFSET), (0, 1, 0) + ) + + if gizmo := self.get_dimension_gizmo_if_visible("height"): + y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True) + gizmo.matrix_basis = mw @ self.compose_gizmo_matrix( + V_(total_run + self.GIZMO_OFFSET, y_pos, 0), (0, 0, 1) + ) + + def _update_tread_dimension_gizmos( + self, mw: Matrix, props, viewing_from_negative_y: bool, total_run: float, riser_height: float + ) -> None: + """Update tread-related dimension gizmos (tread_run, custom first/last tread).""" + if gizmo := self.get_dimension_gizmo_if_visible("tread_run"): + y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False) + if props.custom_tread_lock: + x_offset, z_offset = 0, riser_height + else: + x_offset = props.custom_first_last_tread_run[0] + z_offset = riser_height * 2 + gizmo.matrix_basis = mw @ self.compose_gizmo_matrix( + V_(x_offset, y_pos, z_offset), (1, 0, 0) + ) + + if gizmo := self.get_dimension_gizmo_if_visible("custom_first_tread_run"): + y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False) + gizmo.matrix_basis = mw @ self.compose_gizmo_matrix( + V_(0, y_pos, riser_height), (1, 0, 0) + ) + + if gizmo := self.get_dimension_gizmo_if_visible("custom_last_tread_run"): + y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False) + x_offset = total_run - props.custom_first_last_tread_run[1] + gizmo.matrix_basis = mw @ self.compose_gizmo_matrix( + V_(x_offset, y_pos, props.height), (1, 0, 0) + ) + + def _update_detail_dimension_gizmos( + self, mw: Matrix, props, viewing_from_negative_y: bool, riser_height: float + ) -> None: + """Update detail dimension gizmos (nosing, tread depth, riser height).""" + y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=False) + + self.set_dimension_gizmo_position("nosing_length", mw, V_(0, props.width / 2, riser_height), (-1, 0, 0)) + self.set_dimension_gizmo_position("tread_depth", mw, V_(0, y_pos, riser_height), (0, 0, -1)) + self.set_dimension_gizmo_position("riser_height", mw, V_(props.tread_run, y_pos, 0), (0, 0, 1)) + self.set_dimension_gizmo_position("nosing_depth", mw, V_(-props.nosing_length, props.width / 2, riser_height), (0, 0, -1)) + + def _update_lock_gizmo_position( + self, mw: Matrix, props, viewing_from_negative_y: bool, billboard_rot: Matrix, total_run: float + ) -> None: + """Update lock gizmo position based on Y view direction.""" + y_pos = self.get_y_position_for_view(props, viewing_from_negative_y, use_offset=True) + self.set_icon_gizmo_position( + "lock_gizmo", mw, total_run + 0.5, y_pos, -self.GIZMO_OFFSET, billboard_rot, scale=self.EDITING_ICON_SCALE ) - self.cycle_gizmo.matrix_basis = mw @ local_transform - def draw_prepare(self, context): - """Called before drawing - updates gizmos to face camera.""" - # Call base class implementation - super().draw_prepare(context) + def _update_editing_icon_positions(self, mw, props, viewing_from_negative_y, billboard_rot): + """Update editing icon positions, flipping Y based on viewing angle.""" + if not props.is_editing: + return - # Also update the secondary width gizmo to face camera - if hasattr(self, "gizmo_width_top") and not self.gizmo_width_top.hide: - self.gizmo_width_top.draw_prepare(context) + icon_z = props.height + 0.5 + y_pos = -self.GIZMO_OFFSET if viewing_from_negative_y else props.width + self.GIZMO_OFFSET + + self.set_icon_gizmo_position("validate_gizmo", mw, 0, y_pos, icon_z, billboard_rot) + self.set_icon_gizmo_position("cancel_gizmo", mw, self.ICON_CANCEL_X, y_pos, icon_z, billboard_rot) + self.set_icon_gizmo_position("cycle_gizmo", mw, self.ICON_CYCLE_X, y_pos, icon_z, billboard_rot, scale=0.3) + self.set_icon_gizmo_position( + "tread_lock_gizmo", mw, self.ICON_TREAD_LOCK_X, y_pos, + icon_z - self.EDITING_ICON_SCALE / 2, billboard_rot, scale=self.EDITING_ICON_SCALE + ) + self.set_icon_gizmo_position( + "plus_gizmo", mw, self.ICON_PLUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE + ) + self.set_icon_gizmo_position( + "minus_gizmo", mw, self.ICON_MINUS_X, y_pos, icon_z, billboard_rot, scale=self.ICON_PLUS_MINUS_SCALE + ) diff --git a/src/bonsai/bonsai/bim/module/model/window.py b/src/bonsai/bonsai/bim/module/model/window.py index 4bd4141c89..d899e12c7c 100644 --- a/src/bonsai/bonsai/bim/module/model/window.py +++ b/src/bonsai/bonsai/bim/module/model/window.py @@ -28,8 +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 bonsai.bim.module.drawing import gizmos as gizmo +from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig from ifcopenshell.api.geometry.add_window_representation import DEFAULT_PANEL_SCHEMAS import ifcopenshell.api import ifcopenshell.api.material @@ -43,6 +43,44 @@ from typing import get_args V_ = tool.Blender.V_ +# Window type visibility helpers for dimension gizmos +_MULLION_TYPES = frozenset(( + "DOUBLE_PANEL_VERTICAL", + "TRIPLE_PANEL_BOTTOM", + "TRIPLE_PANEL_TOP", + "TRIPLE_PANEL_LEFT", + "TRIPLE_PANEL_RIGHT", + "TRIPLE_PANEL_VERTICAL", +)) +_TRANSOM_TYPES = frozenset(( + "DOUBLE_PANEL_HORIZONTAL", + "TRIPLE_PANEL_BOTTOM", + "TRIPLE_PANEL_TOP", + "TRIPLE_PANEL_LEFT", + "TRIPLE_PANEL_RIGHT", + "TRIPLE_PANEL_HORIZONTAL", +)) + + +def _has_mullion(props) -> bool: + """Check if the window type uses mullions (vertical dividers).""" + return props.window_type in _MULLION_TYPES + + +def _has_second_mullion(props) -> bool: + """Check if the window type uses a second mullion.""" + return props.window_type == "TRIPLE_PANEL_VERTICAL" + + +def _has_transom(props) -> bool: + """Check if the window type uses transoms (horizontal dividers).""" + return props.window_type in _TRANSOM_TYPES + + +def _has_second_transom(props) -> bool: + """Check if the window type uses a second transom.""" + return props.window_type == "TRIPLE_PANEL_HORIZONTAL" + def update_window_modifier_representation(context: bpy.types.Context) -> None: obj = context.active_object @@ -481,6 +519,7 @@ class AddWindow(bpy.types.Operator, tool.Ifc.Operator): class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.cancel_editing_window" bl_label = "Cancel Editing Window" + bl_description = "Cancel editing and revert window parameters to their previous values" bl_options = {"REGISTER"} def _execute(self, context): @@ -509,6 +548,7 @@ class CancelEditingWindow(bpy.types.Operator, tool.Ifc.Operator): class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.finish_editing_window" bl_label = "Finish Editing Window" + bl_description = "Apply changes and finish editing window parameters" bl_options = {"REGISTER"} def _execute(self, context): @@ -541,6 +581,7 @@ class FinishEditingWindow(bpy.types.Operator, tool.Ifc.Operator): class EnableEditingWindow(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.enable_editing_window" bl_label = "Enable Editing Window" + bl_description = "Enter edit mode to modify window parameters interactively" bl_options = {"REGISTER"} def _execute(self, context): @@ -605,6 +646,26 @@ class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} +def _compute_frame_depth(props) -> float: + """Get the first panel's frame depth value.""" + return props.frame_depth[0] + + +def _apply_frame_depth(props, value: float) -> None: + """Apply a new frame depth value to the first panel, preserving other panels.""" + props.frame_depth = (max(0.0, value),) + tuple(props.frame_depth[1:]) + + +def _compute_frame_thickness(props) -> float: + """Get the first panel's frame thickness value.""" + return props.frame_thickness[0] + + +def _apply_frame_thickness(props, value: float) -> None: + """Apply a new frame thickness value to the first panel, preserving other panels.""" + props.frame_thickness = (max(0.0, value),) + tuple(props.frame_thickness[1:]) + + class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): bl_idname = "OBJECT_GGT_bim_window_edition" bl_label = "Window Editing Gizmo" @@ -617,190 +678,180 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): cancel_editing_operator = "bim.cancel_editing_window" cycle_type_operator = "bim.cycle_window_type" - 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), delta_scale=2.0), - GizmoPropConfig("first_mullion_offset", (1, 0, 0)), - GizmoPropConfig("second_mullion_offset", (1, 0, 0)), - GizmoPropConfig("transom_thickness", (0, 0, 1), delta_scale=2.0), - GizmoPropConfig("first_transom_offset", (0, 0, 1)), - GizmoPropConfig("second_transom_offset", (0, 0, 1)), + dimension_gizmo_props = [ + DimensionGizmoConfig(attr_name="overall_width", axis=(1, 0, 0), min_value=0.01, text_offset_sign=-1), + DimensionGizmoConfig(attr_name="overall_height", axis=(0, 0, 1), min_value=0.01, text_alignment="start"), + DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0)), + DimensionGizmoConfig(attr_name="lining_depth", axis=(0, 1, 0)), + DimensionGizmoConfig(attr_name="lining_thickness", axis=(1, 0, 0)), + DimensionGizmoConfig(attr_name="lining_to_panel_offset_x", axis=(1, 0, 0)), + DimensionGizmoConfig(attr_name="lining_to_panel_offset_y", axis=(0, 1, 0), min_value=-10.0), + DimensionGizmoConfig( + attr_name="frame_depth", + axis=(0, -1, 0), + compute_value=_compute_frame_depth, + apply_value=_apply_frame_depth, + ), + DimensionGizmoConfig( + attr_name="frame_thickness", + axis=(1, 0, 0), + compute_value=_compute_frame_thickness, + apply_value=_apply_frame_thickness, + ), + DimensionGizmoConfig(attr_name="mullion_thickness", axis=(1, 0, 0), delta_scale=2.0, visibility_condition=_has_mullion), + DimensionGizmoConfig(attr_name="first_mullion_offset", axis=(1, 0, 0), visibility_condition=_has_mullion), + DimensionGizmoConfig(attr_name="second_mullion_offset", axis=(1, 0, 0), visibility_condition=_has_second_mullion), + DimensionGizmoConfig(attr_name="transom_thickness", axis=(0, 0, 1), delta_scale=2.0, visibility_condition=_has_transom), + DimensionGizmoConfig(attr_name="first_transom_offset", axis=(0, 0, 1), visibility_condition=_has_transom), + DimensionGizmoConfig(attr_name="second_transom_offset", axis=(0, 0, 1), visibility_condition=_has_second_transom), ] @classmethod def is_element_type(cls, element) -> bool: return tool.Blender.Modifier.is_window(element) - def get_props(self, obj): + def get_props(self, obj: bpy.types.Object): return tool.Model.get_window_props(obj) def get_gizmo_prefs(self): prefs = tool.Blender.get_addon_preferences() return prefs.gizmos.window - def should_hide_gizmo(self, attr_name, props): - """Window-specific visibility rules for gizmos.""" - if not props.is_editing: - return True + def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: + """Position icons beyond the furthest geometry extent based on view direction.""" + obj = context.active_object + if not obj: + return self.ICON_Y_OFFSET + props = self.get_props(obj) - 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) + furthest_positive_y = ( + max(0, props.lining_offset) + + props.lining_depth + + props.lining_to_panel_offset_y ) - rotation = self.get_axis_rotation_matrix((0, 1, 0)) - return translation @ rotation + furthest_negative_y = min(0, props.lining_offset) - 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) + viewing_from_negative_y, _ = self.get_local_view_direction(context, mw) + if viewing_from_negative_y: + return furthest_negative_y - 2 * self.GIZMO_OFFSET + return furthest_positive_y + 2 * self.GIZMO_OFFSET + + def get_dimension_matrix_lining_offset(self, props) -> Matrix: + return self.compose_gizmo_matrix(V_(0, 0, 0), (0, 1, 0)) + + def get_dimension_matrix_lining_depth(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(props.overall_width / 2, props.lining_offset, props.overall_height), (0, 1, 0) + ) + + def get_dimension_matrix_lining_thickness(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(0, props.lining_depth / 2 + props.lining_offset, props.overall_height / 2), (1, 0, 0) ) - 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): + def get_dimension_matrix_lining_to_panel_offset_x(self, props) -> Matrix: 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) + return self.compose_gizmo_matrix( + V_(0, y_full + props.frame_depth[0] + props.lining_offset, props.lining_to_panel_offset_x), (1, 0, 0) ) - rotation = self.get_axis_rotation_matrix((1, 0, 0)) - return translation @ rotation - def get_gizmo_matrix_lining_to_panel_offset_y(self, props): + def get_dimension_matrix_lining_to_panel_offset_y(self, props) -> Matrix: + y_start = props.lining_depth + props.lining_offset + return self.compose_gizmo_matrix( + V_(props.overall_width - props.lining_to_panel_offset_x, y_start, props.lining_to_panel_offset_x), + (0, 1, 0), + ) + + def get_dimension_matrix_frame_depth(self, props) -> Matrix: y_full = self._get_lining_to_panel_offset_y_full(props) - 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): - # Position at the right edge of the mullion (offset + thickness/2) so gizmo movement matches property 1:1 - 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): - # Position at the left edge of the mullion (offset - thickness/2) - 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_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): - # Position at the top edge of the transom (offset + thickness/2) so gizmo movement matches property 1:1 - 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): - # Position at the bottom edge of the transom (offset - thickness/2) - 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_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", + y_start = y_full + props.frame_depth[0] + props.lining_offset + return self.compose_gizmo_matrix( + V_(props.lining_to_panel_offset_x, y_start, props.lining_to_panel_offset_x), (0, -1, 0) ) - def _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 get_dimension_matrix_frame_thickness(self, props) -> Matrix: + y_full = self._get_lining_to_panel_offset_y_full(props) + y_pos = y_full + props.frame_depth[0] + props.lining_offset + return self.compose_gizmo_matrix( + V_(props.lining_to_panel_offset_x, y_pos, props.overall_height / 2), (1, 0, 0) ) - def _has_second_transom(self, props): - """Check if the window type uses a second transom.""" - return props.window_type == "TRIPLE_PANEL_HORIZONTAL" + def get_dimension_matrix_mullion_thickness(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(props.first_mullion_offset - props.mullion_thickness / 2, props.lining_offset, props.overall_height / 2), + (1, 0, 0), + ) - def setup(self, context): - # Use base class methods for common gizmos - self.setup_property_gizmos(context) + def get_dimension_matrix_first_mullion_offset(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(0, props.lining_offset, props.overall_height / 2), (1, 0, 0) + ) + + def get_dimension_matrix_second_mullion_offset(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(0, props.lining_offset, props.overall_height / 2 + 0.1), (1, 0, 0) + ) + + def get_dimension_matrix_transom_thickness(self, props) -> Matrix: + # Offset X when panels are horizontal to avoid overlap with mullion gizmo + x_pos = props.overall_width / 2 - 0.1 if _has_transom(props) else props.overall_width / 2 + return self.compose_gizmo_matrix( + V_(x_pos, props.lining_offset, props.first_transom_offset - props.transom_thickness / 2), + (0, 0, 1), + ) + + def get_dimension_matrix_first_transom_offset(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(props.overall_width / 2, props.lining_offset, 0), (0, 0, 1) + ) + + def get_dimension_matrix_second_transom_offset(self, props) -> Matrix: + return self.compose_gizmo_matrix( + V_(props.overall_width / 2 + 0.1, props.lining_offset, 0), (0, 0, 1) + ) + + def get_dimension_matrix_overall_width(self, props) -> Matrix: + """Position width dimension below the window.""" + return self.compose_gizmo_matrix( + V_(0, props.lining_offset - self.GIZMO_OFFSET, -self.GIZMO_OFFSET), (1, 0, 0) + ) + + def get_dimension_matrix_overall_height(self, props) -> Matrix: + """Position height dimension to the side of the window.""" + return self.compose_gizmo_matrix( + V_(props.overall_width + self.GIZMO_OFFSET, props.lining_offset - self.GIZMO_OFFSET, 0), (0, 0, 1) + ) + + def setup(self, context: bpy.types.Context) -> None: self.setup_editing_gizmos(context) + self.setup_dimension_gizmos(context) - def refresh(self, context): + def refresh(self, context: bpy.types.Context) -> None: + if not self.is_setup_complete(): + return obj = context.active_object if not obj: return props = self.get_props(obj) mw = obj.matrix_world - self.update_property_gizmos(mw, props) self.update_editing_gizmos(context, mw, props) + self.update_dimension_gizmos(mw, props) + + def _update_dimension_gizmo_positions(self, context: bpy.types.Context, mw: Matrix, props) -> None: + """Update dimension gizmo positions based on camera view direction.""" + viewing_from_negative_y, viewing_from_negative_x = self.get_local_view_direction(context, mw) + y_pos = self.get_lining_y_position_for_view(props, viewing_from_negative_y) + + self.set_dimension_gizmo_position("overall_width", mw, V_(0, y_pos, -self.GIZMO_OFFSET), (1, 0, 0)) + + if viewing_from_negative_x: + x_pos = -self.GIZMO_OFFSET + else: + x_pos = props.overall_width + self.GIZMO_OFFSET + self.set_dimension_gizmo_position("overall_height", mw, V_(x_pos, y_pos, 0), (0, 0, 1)) diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index a5105a0947..49b408ba70 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -249,6 +249,8 @@ class GizmoPreferencesDoor(bpy.types.PropertyGroup): overall_width: BoolProperty(name="Overall Width", default=True) threshold_thickness: BoolProperty(name="Threshold Thickness", default=True) threshold_depth: BoolProperty(name="Threshold Depth", default=True) + threshold_offset: BoolProperty(name="Threshold Offset", default=True) + lining_offset: BoolProperty(name="Lining Offset", default=True) lining_depth: BoolProperty(name="Lining Depth", default=True) lining_thickness: BoolProperty(name="Lining Thickness", default=True) transom_offset: BoolProperty(name="Transom Offset", default=True) @@ -263,6 +265,8 @@ class GizmoPreferencesDoor(bpy.types.PropertyGroup): overall_width: bool threshold_thickness: bool threshold_depth: bool + threshold_offset: bool + lining_offset: bool lining_depth: bool lining_thickness: bool transom_offset: bool @@ -278,10 +282,13 @@ class GizmoPreferencesWindow(bpy.types.PropertyGroup): overall_height: BoolProperty(name="Overall Height", default=True) overall_width: BoolProperty(name="Overall Width", default=True) + lining_offset: BoolProperty(name="Lining Offset", default=True) lining_depth: BoolProperty(name="Lining Depth", default=True) lining_thickness: BoolProperty(name="Lining Thickness", default=True) lining_to_panel_offset_x: BoolProperty(name="Lining to Panel Offset X", default=True) lining_to_panel_offset_y: BoolProperty(name="Lining to Panel Offset Y", default=True) + frame_depth: BoolProperty(name="Frame Depth", default=True) + frame_thickness: BoolProperty(name="Frame Thickness", default=True) mullion_thickness: BoolProperty(name="Mullion Thickness", default=True) first_mullion_offset: BoolProperty(name="First Mullion Offset", default=True) second_mullion_offset: BoolProperty(name="Second Mullion Offset", default=True) @@ -292,10 +299,13 @@ class GizmoPreferencesWindow(bpy.types.PropertyGroup): if TYPE_CHECKING: overall_height: bool overall_width: bool + lining_offset: bool lining_depth: bool lining_thickness: bool lining_to_panel_offset_x: bool lining_to_panel_offset_y: bool + frame_depth: bool + frame_thickness: bool mullion_thickness: bool first_mullion_offset: bool second_mullion_offset: bool @@ -311,6 +321,7 @@ class GizmoPreferencesStair(bpy.types.PropertyGroup): height: BoolProperty(name="Height", default=True) tread_run: BoolProperty(name="Tread Run", default=True) tread_depth: BoolProperty(name="Tread Depth", default=True) + riser_height: BoolProperty(name="Riser Height", default=True) nosing_length: BoolProperty(name="Nosing Length", default=True) nosing_depth: BoolProperty(name="Nosing Depth", default=True) total_length_target: BoolProperty(name="Total Length Target", default=True) @@ -326,6 +337,7 @@ class GizmoPreferencesStair(bpy.types.PropertyGroup): height: bool tread_run: bool tread_depth: bool + riser_height: bool nosing_length: bool nosing_depth: bool total_length_target: bool @@ -763,7 +775,8 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): 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 = {p.attr_name for p in GizmoDoorEdition.dimension_gizmo_props} + # Add special gizmos not in dimension_gizmo_props gizmo_prop_names.update(("swing_arc", "flip_arc")) for prop in door_gizmos.__annotations__: if prop in gizmo_prop_names: @@ -773,7 +786,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): 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} + gizmo_prop_names = {p.attr_name for p in GizmoWindowEdition.dimension_gizmo_props} for prop in window_gizmos.__annotations__: if prop in gizmo_prop_names: layout.prop(window_gizmos, prop) @@ -782,8 +795,9 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): 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"} + gizmo_prop_names = {p.attr_name for p in GizmoStairEdition.dimension_gizmo_props} + # Add special gizmos not in dimension_gizmo_props + special_gizmo_names = {"lock", "plus", "minus", "cycle"} for prop in stair_gizmos.__annotations__: if prop in gizmo_prop_names or prop in special_gizmo_names: layout.prop(stair_gizmos, prop) diff --git a/src/bonsai/bonsai/tool/numeric_input.py b/src/bonsai/bonsai/tool/numeric_input.py new file mode 100644 index 0000000000..123cb85f29 --- /dev/null +++ b/src/bonsai/bonsai/tool/numeric_input.py @@ -0,0 +1,168 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 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 . + +from dataclasses import dataclass, field +from typing import Literal, Protocol + +ModalResult = Literal["RUNNING_MODAL", "FINISHED", "CANCELLED"] + + +class IntegerInputOperator(Protocol): + """Protocol for operators using IntegerInputState.""" + + _input: "IntegerInputState" + + def _apply_value(self, context) -> None: ... + def _restore_value(self, context) -> None: ... + def _format_header(self) -> str: ... + + +@dataclass +class IntegerInputState: + """State for keyboard integer input during modal operations.""" + + characters: list[str] = field(default_factory=list) + is_valid: bool = True + min_value: int = 1 + _user_started_typing: bool = False + + @classmethod + def from_value(cls, value: int, min_value: int = 1) -> "IntegerInputState": + """Create state initialized with an existing value.""" + return cls( + characters=list(str(value)), + is_valid=True, + min_value=min_value, + _user_started_typing=False, + ) + + def get_input_string(self) -> str: + return "".join(self.characters) + + def get_value(self) -> int | None: + """Return parsed integer value, or None if invalid.""" + if not self.characters: + return None + try: + value = int(self.get_input_string()) + return value if value >= self.min_value else None + except ValueError: + return None + + def handle_digit(self, digit: str) -> None: + """Handle a digit key press.""" + if not self._user_started_typing: + self.characters.clear() + self._user_started_typing = True + self.characters.append(digit) + self._update_validity() + + def handle_backspace(self) -> None: + """Handle backspace key press.""" + self._user_started_typing = True + if self.characters: + self.characters.pop() + self._update_validity() + + def _update_validity(self) -> None: + """Update is_valid based on current input.""" + self.is_valid = self.get_value() is not None + + +def handle_numeric_event(state: IntegerInputState, event) -> tuple[bool, ModalResult | None]: + """ + Process a Blender event for numeric input. + + Returns: + (handled, result): + - handled: True if the event was consumed + - result: "FINISHED", "CANCELLED", or None if still running + """ + if event.value != "PRESS": + return False, None + + # Digit input + if event.ascii and event.ascii in "0123456789": + state.handle_digit(event.ascii) + return True, None + + # Backspace + if event.type == "BACK_SPACE": + state.handle_backspace() + return True, None + + # Confirm + if event.type in {"RET", "NUMPAD_ENTER"}: + if state.is_valid: + return True, "FINISHED" + return True, None # Invalid - don't confirm yet + + # Click confirm + if event.type == "LEFTMOUSE": + if state.characters and state.is_valid: + return True, "FINISHED" + return True, "FINISHED" # Empty or invalid - still finish + + # Cancel + if event.type in {"ESC", "RIGHTMOUSE"}: + return True, "CANCELLED" + + return False, None + + +def run_integer_input_modal(op: IntegerInputOperator, context, event) -> set[str]: + """ + Run the modal loop for an integer input operator. + + The operator must implement: + - _input: IntegerInputState + - _apply_value(context): Apply the current input value + - _restore_value(context): Restore original value on cancel + - _format_header(): Return the header string to display + + Returns the Blender modal return set (e.g., {"RUNNING_MODAL"}). + """ + handled, result = handle_numeric_event(op._input, event) + + if handled: + if result == "FINISHED": + op._apply_value(context) + cleanup_header(context) + return {"FINISHED"} + elif result == "CANCELLED": + op._restore_value(context) + cleanup_header(context) + return {"CANCELLED"} + else: + op._apply_value(context) + update_header(context, op._format_header()) + return {"RUNNING_MODAL"} + + return {"RUNNING_MODAL"} + + +def update_header(context, text: str) -> None: + """Update the area header text.""" + if context.area: + context.area.header_text_set(text) + + +def cleanup_header(context) -> None: + """Clear the area header text.""" + if context.area: + context.area.header_text_set(None)