Files
IfcOpenShell/src/bonsai/bonsai/bim/module/drawing/gizmos.py
T
Gorgious56 fb70c64138 Add tool.Parametric registry and lifecycle mixins
Establish a single source of truth for parametric element types (door,
window, stair, railing, roof). tool.Parametric.EDIT_TYPES drives:
- BIM<Name>Properties PointerProperty attachment via the registry
- GizmoPreferences<Name> class registration in bim/__init__.py
- save-time auto-commit of pending draft edits
- the refresh_post_commit epilogue called from IfcStore after every IFC
  mutation, which fixes the stale-header bug where in-place hotkey
  mutations (S_E / C_E) left BIMModelProperties and the gizmo cache
  pointing at obsolete values.

Refactors door/window/railing/roof onto shared mixins from
bim/parametric_lifecycle.py (FeatureModifierEditMixin and
PathPreservingEditMixin); stair gets the lock-gizmo refactor and
frame-cache integration. Behavior preserved.

Adds BaseParametricGizmoGroup._prime_frame_caches so the parametric
gizmos stop re-deriving preferences, view direction, and billboard
rotation per frame; reorders poll() to short-circuit on the cheapest
predicate first. Adds the icon library + BillboardingGizmoGroupMixin
that the wall feature in the next commit will consume.

Generated with the assistance of an AI coding tool.
2026-06-11 18:30:09 +02:00

5187 lines
196 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
# Copyright (C) 2020, 2021 Maxim Vasilyev <qwiglydee@gmail.com>
#
# 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 <http://www.gnu.org/licenses/>.
#
# This file was modified with the assistance of an AI coding tool.
"""
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__ = [ # noqa: RUF022 (unsorted `__all__`)
"GizmoColor",
"GizmoAxis",
"TextAlignment",
"CoordinateSpace",
"ModalState",
"DimensionGizmoConfig",
"DimensionDrawConfig",
"ViewDirection",
"GizmoModalContext",
"get_modal_context",
"get_validated_modal_context",
"ParametricProps",
"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",
"CycleTypeMixin",
"BaseParametricGizmoGroup",
"UglyDotGizmo",
"ExtrusionGuidesGizmo",
"ExtrusionWidget",
]
import math
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from enum import Enum
from typing import Any, Literal, Protocol, get_args, runtime_checkable
import blf
import bpy
import gpu
import numpy as np
from bpy import types
from bpy_extras import view3d_utils
from bpy_extras.view3d_utils import (
location_3d_to_region_2d,
region_2d_to_origin_3d,
region_2d_to_vector_3d,
)
from gpu_extras.batch import batch_for_shader
from ifcopenshell.util.unit import si_conversions
from mathutils import Matrix, Vector, geometry
from mathutils.geometry import intersect_line_line
from mathutils.kdtree import KDTree
import bonsai.tool as tool
from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader
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
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
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
class GizmoColor(Enum):
"""Color identifiers for dimension gizmos.
Maps axis directions to colors following BIM/CAD conventions:
- RED: X-axis (width)
- GREEN: Y-axis (depth)
- BLUE: Z-axis (height)
Use GizmoColor.from_axis() to auto-derive color from axis direction.
"""
RED = "RED"
GREEN = "GREEN"
BLUE = "BLUE"
@classmethod
def from_axis(cls, axis: tuple[int, int, int]) -> "GizmoColor":
"""Derive color from axis direction.
Args:
axis: Direction tuple (x, y, z) with at least one non-zero component.
Returns:
GizmoColor based on the first non-zero axis component.
"""
if axis[0] != 0:
return cls.RED
elif axis[1] != 0:
return cls.GREEN
return cls.BLUE
class TextAlignment(Enum):
"""Text alignment options for dimension gizmo labels.
Controls where the dimension value text is positioned along the dimension line.
"""
START = "start" # Align text to the start of the dimension line
CENTER = "center" # Center text on the dimension line (default)
END = "end" # Align text to the end of the dimension line
# Type alias for gizmo axis direction tuples
# Each component must be -1, 0, or 1 to indicate direction along that axis
GizmoAxis = tuple[Literal[-1, 0, 1], Literal[-1, 0, 1], Literal[-1, 0, 1]]
class CoordinateSpace(Enum):
"""Coordinate space identifiers for gizmo positioning.
Clarifies which space a position or direction is expressed in:
- LOCAL: Object-local coordinates (relative to element origin)
- WORLD: World/scene coordinates (absolute position)
- SCREEN: 2D screen-space coordinates (pixels)
Usage:
# Document coordinate space in function signatures
def get_position(self, space: CoordinateSpace = CoordinateSpace.LOCAL) -> Vector:
...
# Or use as documentation in comments
local_pos = Vector((0, 0, 1)) # CoordinateSpace.LOCAL
world_pos = matrix_world @ local_pos # CoordinateSpace.WORLD
"""
LOCAL = "local" # Object-local space (relative to element matrix_world)
WORLD = "world" # World/scene space (absolute coordinates)
SCREEN = "screen" # 2D screen space (pixel coordinates)
class ModalState(Enum):
"""State machine states for modal gizmo operations.
Used to track the current interaction mode during gizmo manipulation.
Helps organize modal operator logic and determine valid state transitions.
States:
IDLE: No interaction active, waiting for user input
DRAGGING: User is dragging the gizmo with the mouse
KEYBOARD_INPUT: User is typing a numeric value
SNAPPING: Dragging with snap enabled (Ctrl held)
PRECISION: Dragging with precision mode (Shift held)
State transitions:
IDLE -> DRAGGING: Mouse press on gizmo
IDLE -> KEYBOARD_INPUT: Numeric key press
DRAGGING -> SNAPPING: Ctrl pressed during drag
DRAGGING -> PRECISION: Shift pressed during drag
SNAPPING -> DRAGGING: Ctrl released
PRECISION -> DRAGGING: Shift released
* -> IDLE: Mouse release, Enter, Escape
"""
IDLE = "idle"
DRAGGING = "dragging"
KEYBOARD_INPUT = "keyboard_input"
SNAPPING = "snapping"
PRECISION = "precision"
def is_active(self) -> bool:
"""Check if this state represents an active interaction."""
return self != ModalState.IDLE
def allows_keyboard_input(self) -> bool:
"""Check if this state allows transitioning to keyboard input."""
return self in (ModalState.IDLE, ModalState.DRAGGING)
@dataclass(slots=True)
class GizmoModalContext:
"""Typed context for modal gizmo operations.
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
# Module-level instance for modal gizmo context
_gizmo_modal_context = GizmoModalContext()
def get_modal_context() -> GizmoModalContext:
"""Get the global modal gizmo context.
Provides access to the module-level context without exposing the private variable.
Use this when reading context values that may be None.
Returns:
The global GizmoModalContext instance.
"""
return _gizmo_modal_context
def get_validated_modal_context() -> GizmoModalContext:
"""Get the modal context, validating that essential fields are set.
Use this when the context is expected to be fully initialized (e.g., during
modal operator execution). Raises RuntimeError if the context is incomplete.
Returns:
The global GizmoModalContext instance with essential fields validated.
Raises:
RuntimeError: If active_gizmo or gizmo_group is None.
"""
ctx = _gizmo_modal_context
if ctx.active_gizmo is None:
raise RuntimeError("Modal context not initialized: active_gizmo is None")
if ctx.gizmo_group is None:
raise RuntimeError("Modal context not initialized: gizmo_group is None")
return ctx
class GPUStateScope:
"""Context manager for saving and restoring GPU state.
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: TextAlignment | str = TextAlignment.CENTER,
display_text: str | None = None,
) -> 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: TextAlignment enum value
display_text: Pre-formatted label. If provided, used verbatim instead of
formatting `value`.
"""
# Normalize string to enum for comparison
if isinstance(alignment, str):
alignment = TextAlignment(alignment)
if display_text is not None:
text = display_text
else:
is_negative = value < 0
text = tool.Unit.format_distance(abs(value))
if is_negative:
text = "-" + text
font_id = 0
font_size = tool.Blender.scale_font_size(self.VALUE_FONT_SIZE)
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 == TextAlignment.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)
@dataclass(slots=True, frozen=True)
class DimensionDrawConfig:
"""Immutable configuration for drawing a dimension line.
Groups the many parameters needed by DimensionRenderer.draw() into a
single configuration object, improving readability and maintainability.
Attributes:
start_world: World-space start position
end_world: World-space end position
axis_world: Normalized axis direction in world space
dimension_length: Length of the dimension (for drawing the line)
color: Base color (r, g, b)
alpha: Base alpha (0.0 to 1.0)
is_highlight: Whether gizmo is highlighted/hovered
highlight_color: Highlight color (r, g, b)
highlight_alpha: Highlight alpha
show_start_arrow: Whether to show arrow at start
show_end_arrow: Whether to show arrow at end
show_extension_lines: Whether to show extension lines
text_offset_sign: 1 for above/right, -1 for below/left
text_alignment: TextAlignment value for text positioning along line
prop_name: Property name for tooltip (shown when highlighted)
display_value: Value to display as text (can be negative); uses dimension_length if None
"""
start_world: Vector
end_world: Vector
axis_world: Vector
dimension_length: float
color: tuple[float, float, float] = (1.0, 1.0, 1.0)
alpha: float = 1.0
is_highlight: bool = False
highlight_color: tuple[float, float, float] = (1.0, 1.0, 0.5)
highlight_alpha: float = 1.0
show_start_arrow: bool = False
show_end_arrow: bool = True
show_extension_lines: bool = True
text_offset_sign: Literal[-1, 1] = 1
text_alignment: TextAlignment = TextAlignment.CENTER
prop_name: str | None = None
display_value: float | None = None
@dataclass(slots=True, frozen=True)
class ViewDirection:
"""Immutable representation of camera view direction relative to an element's local space.
Provides a cleaner API than tuple unpacking for view-dependent gizmo positioning.
With:
view = self.get_view_direction(context, mw)
if view.from_back: ...
Attributes:
from_negative_y: True if camera is on the -Y side (viewing from "back")
from_negative_x: True if camera is on the -X side (viewing from "left")
Properties:
from_back: Alias for from_negative_y (more intuitive for doors/windows)
from_front: Inverse of from_back
from_left: Alias for from_negative_x
from_right: Inverse of from_left
"""
from_negative_y: bool = False
from_negative_x: bool = False
@property
def from_back(self) -> bool:
"""True if viewing from the back (-Y) side of the element."""
return self.from_negative_y
@property
def from_front(self) -> bool:
"""True if viewing from the front (+Y) side of the element."""
return not self.from_negative_y
@property
def from_left(self) -> bool:
"""True if viewing from the left (-X) side of the element."""
return self.from_negative_x
@property
def from_right(self) -> bool:
"""True if viewing from the right (+X) side of the element."""
return not self.from_negative_x
@classmethod
def from_context(cls, context: bpy.types.Context, world_matrix: Matrix) -> "ViewDirection":
"""Create ViewDirection from Blender context and object world matrix.
Args:
context: Blender context with region_data
world_matrix: Object's world transformation matrix
Returns:
ViewDirection instance, defaults to (False, False) if region data unavailable.
"""
rv3d = context.region_data
if not rv3d:
return cls()
view_direction = Vector(rv3d.view_rotation @ Vector((0, 0, -1)))
local_view_dir = world_matrix.inverted().to_3x3() @ view_direction
return cls(
from_negative_y=local_view_dir.y < 0,
from_negative_x=local_view_dir.x < 0,
)
class DimensionRenderer:
"""Handles rendering of dimension line graphics.
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()
config = DimensionDrawConfig(start_world, end_world, axis_world, length, color)
renderer.draw(context, config)
# Or use legacy method signature:
renderer.draw(context, start_world, end_world, ...)
"""
_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: TextAlignment = TextAlignment.CENTER,
prop_name: str | None = None,
display_value: float | None = None,
display_text: 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 drawing the line)
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: TextAlignment enum for text positioning
prop_name: Property name for tooltip (shown when highlighted)
display_value: Value to display as text (can be negative); uses dimension_length if None
display_text: Pre-formatted label string. If provided, used verbatim instead of
formatting `display_value` via tool.Unit.format_distance.
"""
if dimension_length < 0:
return
# Use display_value for text if provided, otherwise use dimension_length
text_value = display_value if display_value is not None else dimension_length
region = context.region
rv3d = context.region_data
if not region or not rv3d:
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,
text_value,
text_color,
text_offset_sign,
text_alignment,
display_text,
)
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(slots=True, frozen=True)
class SnapCache:
"""Immutable 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(slots=True)
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 = tool.Unit.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
@runtime_checkable
class ParametricProps(Protocol):
"""Protocol defining the common interface for parametric element properties.
All parametric property classes (BIMDoorProperties, BIMWindowProperties,
BIMStairProperties, etc.) should implement this interface. This enables
type-safe code in BaseParametricGizmoGroup without importing concrete classes.
Example:
def update_gizmos(self, props: ParametricProps) -> None:
if props.is_editing:
# Safe to access common properties
...
"""
is_editing: bool
@dataclass(slots=True)
class DimensionGizmoConfig:
"""Configuration for a dimension gizmo.
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.
matrix_position: Optional function(props) -> Vector for gizmo position.
If provided, eliminates need for get_dimension_matrix_{attr_name} method.
The returned Vector is the local-space position where the gizmo origin
will be placed. Combined with axis to create the full transformation matrix.
text_formatter: Optional function(props, value) -> str for the dimension label.
Receives the props bag and the post-`compute_value` display value
(i.e. the same number `apply_value` consumes during drag — for the
wall slope gizmo this is the displacement, NOT the underlying
`x_angle`). The raw underlying attribute is accessible as
`getattr(props, attr_name)`. If None, falls back to the default
`tool.Unit.format_distance(abs(value))` with negative-sign handling.
"""
attr_name: str
axis: GizmoAxis
color: GizmoColor | str | None = None # GizmoColor enum, string ("RED"/"GREEN"/"BLUE"), or None for auto
prop_name: str | None = None
min_value: float = 0.0
invert_delta: bool = False
delta_scale: float = 1.0
text_offset_sign: Literal[-1, 1] = 1
text_alignment: TextAlignment | str = TextAlignment.CENTER
show_start_arrow: bool = False
show_end_arrow: bool = True
compute_value: Callable[[Any], float] | None = None
apply_value: Callable[[Any, float], None] | None = None
visibility_condition: Callable[[Any], bool] | None = None
matrix_position: Callable[[Any], "Vector"] | None = None # Optional: function(props) -> Vector position
text_formatter: Callable[[Any, float], str] | None = None # Optional: function(props, value) -> label text
def __post_init__(self):
# Validate attr_name
if not self.attr_name or not isinstance(self.attr_name, str):
raise ValueError("attr_name must be a non-empty string")
# Validate axis
if len(self.axis) != 3:
raise ValueError(f"axis must be a 3-tuple, got {len(self.axis)} elements")
if not any(self.axis):
raise ValueError("axis must have at least one non-zero component")
# Normalize and validate text_alignment
if isinstance(self.text_alignment, str):
try:
self.text_alignment = TextAlignment(self.text_alignment)
except ValueError:
valid = [e.value for e in TextAlignment]
raise ValueError(f"text_alignment must be one of {valid}, got '{self.text_alignment}'")
elif not isinstance(self.text_alignment, TextAlignment):
raise ValueError(f"text_alignment must be TextAlignment enum or string, got {type(self.text_alignment)}")
# Validate text_offset_sign
if self.text_offset_sign not in (1, -1):
raise ValueError(f"text_offset_sign must be 1 or -1, got {self.text_offset_sign}")
# Normalize and validate color
if self.color is None:
# Auto-derive from axis direction
self.color = GizmoColor.from_axis(self.axis)
elif isinstance(self.color, str):
# Convert string to enum
try:
self.color = GizmoColor(self.color)
except ValueError:
raise ValueError(f"color must be 'RED', 'GREEN', or 'BLUE', got '{self.color}'")
elif not isinstance(self.color, GizmoColor):
raise ValueError(f"color must be GizmoColor enum, string, or None, got {type(self.color)}")
# Auto-derive prop_name from attr_name if not specified
if self.prop_name is None:
self.prop_name = self.attr_name.replace("_", " ").title()
def __repr__(self) -> str:
"""Concise representation showing key configuration values."""
parts = [f"attr_name={self.attr_name!r}", f"axis={self.axis}"]
if self.color:
parts.append(f"color={self.color.name}")
if self.visibility_condition:
parts.append("visibility_condition=<fn>")
if self.matrix_position:
parts.append("matrix_position=<fn>")
if self.compute_value:
parts.append("compute_value=<fn>")
if self.min_value != 0.0:
parts.append(f"min_value={self.min_value}")
if self.invert_delta:
parts.append("invert_delta=True")
return f"DimensionGizmoConfig({', '.join(parts)})"
class SnapManager:
"""Manages snap point visualization and mesh snapping with caching."""
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."""
tool.Blender.update_all_viewports()
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)
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 billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = 0.5) -> Matrix:
"""Compose the standard icon ``matrix_basis``: translate to ``world_pos``, billboard
to the camera, then uniformly scale. Replaces the repeated
``Matrix.Translation(...) @ billboard_rot @ Matrix.Scale(scale, 4)`` pattern."""
return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4)
def setup_icon_gizmo(
gizmo_group: bpy.types.GizmoGroup,
gizmo_type: str,
color: tuple[float, float, float],
highlight_color: tuple[float, float, float],
operator: str,
alpha: float = 0.8,
) -> bpy.types.Gizmo:
"""Create and configure a stand-alone icon gizmo with the Bonsai defaults
(no draw-scale, fixed alpha, click-to-operator). Use this from any
``GizmoGroup.setup`` to avoid hand-rolling the same five property assignments."""
gizmo = gizmo_group.gizmos.new(gizmo_type)
gizmo.use_draw_scale = False
gizmo.color = color
gizmo.color_highlight = highlight_color
gizmo.alpha = alpha
gizmo.target_set_operator(operator)
return gizmo
# --- Tris geometry helpers ----------------------------------------------------
# Shared by the icon ``bpy.types.Gizmo`` subclasses defined later in this module.
# Each gizmo declares a flat ``tris`` tuple of (x, y, z) vertices grouped into
# triangles of 3; these helpers compose tris from primitives so the per-gizmo
# definitions stay small and visually readable.
def rect_tris(x0: float, y0: float, x1: float, y1: float) -> tuple[tuple[float, float, float], ...]:
"""Two triangles forming an axis-aligned rectangle from ``(x0, y0)`` to ``(x1, y1)``,
in the Z=0 plane (the convention for icon gizmos)."""
return (
(x0, y0, 0.0),
(x0, y1, 0.0),
(x1, y1, 0.0),
(x0, y0, 0.0),
(x1, y1, 0.0),
(x1, y0, 0.0),
)
def swap_xy_tris(
tris: tuple[tuple[float, float, float], ...],
) -> tuple[tuple[float, float, float], ...]:
"""Reflect a ``tris`` tuple across the Y=X diagonal — useful when a "vertical"
sibling of a "horizontal" icon should otherwise be a literal copy."""
return tuple((y, x, z) for x, y, z in tris)
class TrisGizmoMixin:
"""Mixin for stand-alone ``bpy.types.Gizmo`` classes whose only behaviour is
drawing a static ``tris`` triangle tuple. Subclasses set the class-level
``tris`` and ``bl_idname`` attributes; the mixin supplies ``setup`` / ``draw`` /
``draw_select``. Use only with gizmos that have no per-instance state beyond
``custom_shape``."""
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 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),
(-1, +1, +1),
(+1, -1, +1), # top
(+1, -1, +1),
(-1, +1, +1),
(-1, -1, +1),
(+1, +1, +1),
(+1, -1, +1),
(+1, +1, -1), # right
(+1, +1, -1),
(+1, -1, +1),
(+1, -1, -1),
(+1, +1, +1),
(+1, +1, -1),
(-1, +1, +1), # back
(-1, +1, +1),
(+1, +1, -1),
(-1, +1, -1),
(-1, -1, -1),
(-1, +1, -1),
(+1, -1, -1), # bot
(+1, -1, -1),
(-1, +1, -1),
(+1, +1, -1),
(-1, -1, -1),
(-1, -1, +1),
(-1, +1, -1), # left
(-1, +1, -1),
(-1, -1, +1),
(-1, +1, +1),
(-1, -1, -1),
(+1, -1, -1),
(-1, -1, +1), # front
(-1, -1, +1),
(+1, -1, -1),
(+1, -1, +1),
)
DISC = (
(0.0, 0.0, 0.0),
(1.0, 0.0, 0),
(0.8660254037844387, 0.49999999999999994, 0),
(0.0, 0.0, 0.0),
(0.8660254037844387, 0.49999999999999994, 0),
(0.5000000000000001, 0.8660254037844386, 0),
(0.0, 0.0, 0.0),
(0.5000000000000001, 0.8660254037844386, 0),
(6.123233995736766e-17, 1.0, 0),
(0.0, 0.0, 0.0),
(6.123233995736766e-17, 1.0, 0),
(-0.4999999999999998, 0.8660254037844387, 0),
(0.0, 0.0, 0.0),
(-0.4999999999999998, 0.8660254037844387, 0),
(-0.8660254037844385, 0.5000000000000003, 0),
(0.0, 0.0, 0.0),
(-0.8660254037844385, 0.5000000000000003, 0),
(-1.0, 1.2246467991473532e-16, 0),
(0.0, 0.0, 0.0),
(-1.0, 1.2246467991473532e-16, 0),
(-0.8660254037844388, -0.4999999999999997, 0),
(0.0, 0.0, 0.0),
(-0.8660254037844388, -0.4999999999999997, 0),
(-0.5000000000000004, -0.8660254037844384, 0),
(0.0, 0.0, 0.0),
(-0.5000000000000004, -0.8660254037844384, 0),
(-1.8369701987210297e-16, -1.0, 0),
(0.0, 0.0, 0.0),
(-1.8369701987210297e-16, -1.0, 0),
(0.49999999999999933, -0.866025403784439, 0),
(0.0, 0.0, 0.0),
(0.49999999999999933, -0.866025403784439, 0),
(0.8660254037844384, -0.5000000000000004, 0),
(0.0, 0.0, 0.0),
(0.8660254037844384, -0.5000000000000004, 0),
(1.0, 0.0, 0),
)
X3DISC = (
(0.0, 0.0, 0.0),
(1.0, 0.0, 0),
(0.8660254037844387, 0.49999999999999994, 0),
(0.0, 0.0, 0.0),
(0.8660254037844387, 0.49999999999999994, 0),
(0.5000000000000001, 0.8660254037844386, 0),
(0.0, 0.0, 0.0),
(0.5000000000000001, 0.8660254037844386, 0),
(6.123233995736766e-17, 1.0, 0),
(0.0, 0.0, 0.0),
(6.123233995736766e-17, 1.0, 0),
(-0.4999999999999998, 0.8660254037844387, 0),
(0.0, 0.0, 0.0),
(-0.4999999999999998, 0.8660254037844387, 0),
(-0.8660254037844385, 0.5000000000000003, 0),
(0.0, 0.0, 0.0),
(-0.8660254037844385, 0.5000000000000003, 0),
(-1.0, 1.2246467991473532e-16, 0),
(0.0, 0.0, 0.0),
(-1.0, 1.2246467991473532e-16, 0),
(-0.8660254037844388, -0.4999999999999997, 0),
(0.0, 0.0, 0.0),
(-0.8660254037844388, -0.4999999999999997, 0),
(-0.5000000000000004, -0.8660254037844384, 0),
(0.0, 0.0, 0.0),
(-0.5000000000000004, -0.8660254037844384, 0),
(-1.8369701987210297e-16, -1.0, 0),
(0.0, 0.0, 0.0),
(-1.8369701987210297e-16, -1.0, 0),
(0.49999999999999933, -0.866025403784439, 0),
(0.0, 0.0, 0.0),
(0.49999999999999933, -0.866025403784439, 0),
(0.8660254037844384, -0.5000000000000004, 0),
(0.0, 0.0, 0.0),
(0.8660254037844384, -0.5000000000000004, 0),
(1.0, 0.0, 0),
(0.0, 0.0, 0.0),
(0, 1.0, 0.0),
(0, 0.8660254037844387, 0.49999999999999994),
(0.0, 0.0, 0.0),
(0, 0.8660254037844387, 0.49999999999999994),
(0, 0.5000000000000001, 0.8660254037844386),
(0.0, 0.0, 0.0),
(0, 0.5000000000000001, 0.8660254037844386),
(0, 6.123233995736766e-17, 1.0),
(0.0, 0.0, 0.0),
(0, 6.123233995736766e-17, 1.0),
(0, -0.4999999999999998, 0.8660254037844387),
(0.0, 0.0, 0.0),
(0, -0.4999999999999998, 0.8660254037844387),
(0, -0.8660254037844385, 0.5000000000000003),
(0.0, 0.0, 0.0),
(0, -0.8660254037844385, 0.5000000000000003),
(0, -1.0, 1.2246467991473532e-16),
(0.0, 0.0, 0.0),
(0, -1.0, 1.2246467991473532e-16),
(0, -0.8660254037844388, -0.4999999999999997),
(0.0, 0.0, 0.0),
(0, -0.8660254037844388, -0.4999999999999997),
(0, -0.5000000000000004, -0.8660254037844384),
(0.0, 0.0, 0.0),
(0, -0.5000000000000004, -0.8660254037844384),
(0, -1.8369701987210297e-16, -1.0),
(0.0, 0.0, 0.0),
(0, -1.8369701987210297e-16, -1.0),
(0, 0.49999999999999933, -0.866025403784439),
(0.0, 0.0, 0.0),
(0, 0.49999999999999933, -0.866025403784439),
(0, 0.8660254037844384, -0.5000000000000004),
(0.0, 0.0, 0.0),
(0, 0.8660254037844384, -0.5000000000000004),
(0, 1.0, 0.0),
(0.0, 0.0, 0.0),
(0.0, 0, 1.0),
(0.49999999999999994, 0, 0.8660254037844387),
(0.0, 0.0, 0.0),
(0.49999999999999994, 0, 0.8660254037844387),
(0.8660254037844386, 0, 0.5000000000000001),
(0.0, 0.0, 0.0),
(0.8660254037844386, 0, 0.5000000000000001),
(1.0, 0, 6.123233995736766e-17),
(0.0, 0.0, 0.0),
(1.0, 0, 6.123233995736766e-17),
(0.8660254037844387, 0, -0.4999999999999998),
(0.0, 0.0, 0.0),
(0.8660254037844387, 0, -0.4999999999999998),
(0.5000000000000003, 0, -0.8660254037844385),
(0.0, 0.0, 0.0),
(0.5000000000000003, 0, -0.8660254037844385),
(1.2246467991473532e-16, 0, -1.0),
(0.0, 0.0, 0.0),
(1.2246467991473532e-16, 0, -1.0),
(-0.4999999999999997, 0, -0.8660254037844388),
(0.0, 0.0, 0.0),
(-0.4999999999999997, 0, -0.8660254037844388),
(-0.8660254037844384, 0, -0.5000000000000004),
(0.0, 0.0, 0.0),
(-0.8660254037844384, 0, -0.5000000000000004),
(-1.0, 0, -1.8369701987210297e-16),
(0.0, 0.0, 0.0),
(-1.0, 0, -1.8369701987210297e-16),
(-0.866025403784439, 0, 0.49999999999999933),
(0.0, 0.0, 0.0),
(-0.866025403784439, 0, 0.49999999999999933),
(-0.5000000000000004, 0, 0.8660254037844384),
(0.0, 0.0, 0.0),
(-0.5000000000000004, 0, 0.8660254037844384),
(0.0, 0, 1.0),
)
class CustomGizmo:
# FIXME: highlighting/selection doesn't work
def draw_very_custom_shape(self, ctx, custom_shape, select_id=None):
shader_wrapper, batch = custom_shape
shader = shader_wrapper.get_shader()
shader.bind()
if select_id is not None:
gpu.select.load_id(select_id)
else:
if self.is_highlight:
color = (*self.color_highlight, self.alpha_highlight)
else:
color = (*self.color, self.alpha)
shader.uniform_float("color", color)
shader_wrapper.glenable()
shader_wrapper.uniform_region(ctx)
with gpu.matrix.push_pop():
# 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)
gpu.state.blend_set("NONE")
class OffsetHandle:
"""Handling mouse to offset gizmo from base along Z axis"""
# FIXME: works a bit weird for rotated objects
def invoke(self, ctx, event):
self.init_value = self.target_get_value("offset") / self.scale_value
coordz = self.project_mouse(ctx, event)
if coordz is None:
return {"CANCELLED"}
self.init_coordz = coordz
return {"RUNNING_MODAL"}
def modal(self, ctx, event, tweak):
coordz = self.project_mouse(ctx, event)
if coordz is None:
return {"CANCELLED"}
delta = coordz - self.init_coordz
if "PRECISE" in tweak:
delta /= 10.0
value = max(0, self.init_value + delta)
value *= self.scale_value
# ctx.area.header_text_set(f"coords: {self.init_coordz} - {coordz}, delta: {delta}, value: {value}")
ctx.area.header_text_set(f"Depth: {value}")
self.target_set_value("offset", value)
return {"RUNNING_MODAL"}
def project_mouse(self, ctx, event):
"""Projecting mouse coords to local axis Z"""
# logic from source/blender/editors/gizmo_library/gizmo_types/arrow3d_gizmo.c:gizmo_arrow_modal
mouse = Vector((event.mouse_region_x, event.mouse_region_y))
region = ctx.region
region3d = ctx.region_data
ray_orig = view3d_utils.region_2d_to_origin_3d(region, region3d, mouse)
ray_norm = view3d_utils.region_2d_to_vector_3d(region, region3d, mouse)
# 'arrow' origin and direction
base = Vector((0, 0, 0))
axis = Vector((0, 0, 1))
# projection of the arrow to a plane, perpendicular to view ray
axis_proj = axis - ray_norm * axis.dot(ray_norm)
# intersection of the axis with the plane through view origin perpendicular to the arrow projection
coords = geometry.intersect_line_plane(base, axis, ray_orig, axis_proj)
return coords.z
def exit(self, ctx, cancel):
if cancel:
self.target_set_value("offset", self.init_value)
else:
self.group.update(ctx)
class UglyDotGizmo(OffsetHandle, types.Gizmo):
"""three orthogonal circles"""
bl_idname = "BIM_GT_uglydot_3d"
bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},)
__slots__ = (
"scale_value",
"custom_shape",
"init_value",
"init_coordz",
)
def setup(self):
self.custom_shape = self.new_custom_shape(type="TRIS", verts=X3DISC)
def refresh(self):
offset = self.target_get_value("offset") / self.scale_value
self.matrix_offset.translation.z = offset
def draw(self, ctx):
self.refresh()
self.draw_custom_shape(self.custom_shape)
def draw_select(self, ctx, select_id):
self.refresh()
self.draw_custom_shape(self.custom_shape, select_id=select_id)
class ExtrusionGuidesGizmo(CustomGizmo, types.Gizmo):
"""Extrusion guides
Noninteractive gizmo to indicate extrusion depth and planes.
Draws main segment and orthogonal cross at endpoints.
"""
bl_idname = "BIM_GT_extrusion_guides"
bl_target_properties = ({"id": "depth", "type": "FLOAT", "array_length": 1},)
__slots__ = ("scale_value", "custom_shape")
def setup(self):
"""setup `custom_shape`"""
shader_wrapper = ExtrusionGuidesShader()
verts = [Vector((0, 0, 0)), Vector((0, 0, 1))]
verts, edges = shader_wrapper.process_geometry(verts)
if not tool.Blender.validate_shader_batch_data(verts, edges):
verts, edges = [], []
self.custom_shape = shader_wrapper, shader_wrapper.batch(
pos=verts,
indices=edges,
)
def draw(self, ctx):
self.refresh()
self.draw_very_custom_shape(ctx, self.custom_shape)
def refresh(self):
depth = self.target_get_value("depth") / self.scale_value
self.matrix_offset.col[2][2] = depth # z-scaled
class ExtrusionWidget(types.GizmoGroup):
bl_idname = "bim.extrusion_widget"
bl_label = "Extrusion Gizmos"
bl_space_type = "VIEW_3D"
bl_region_type = "WINDOW"
bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"}
@classmethod
def poll(cls, context):
obj = context.active_object
return (
obj
and (data := obj.data)
and isinstance(data, bpy.types.Mesh)
and tool.Geometry.get_mesh_props(data).ifc_parameters.get("IfcExtrudedAreaSolid/Depth") is not None
)
def setup(self, context: bpy.types.Context) -> None:
target = context.object
if not target:
return
mesh = target.data
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()
theme = context.preferences.themes[0].user_interface
scale_value = self.get_scale_value(context.scene.unit_settings.system, context.scene.unit_settings.length_unit)
# setup handle
gz = self.handle = self.gizmos.new("BIM_GT_uglydot_3d")
gz.matrix_basis = basis
gz.scale_basis = 0.1
gz.color = gz.color_highlight = tuple(theme.gizmo_primary)
gz.alpha = 0.5
gz.alpha_highlight = 1.0
gz.use_draw_modal = True
gz.target_set_prop("offset", prop, "value")
gz.scale_value = scale_value
# setup guides
gz = self.guides = self.gizmos.new("BIM_GT_extrusion_guides")
gz.matrix_basis = basis
gz.color = gz.color_highlight = tuple(theme.gizmo_secondary)
gz.alpha = gz.alpha_highlight = 0.75
gz.use_draw_modal = True
gz.target_set_prop("depth", prop, "value")
gz.scale_value = scale_value
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
def update(self, context: bpy.types.Context) -> None:
"""updating object"""
bpy.ops.bim.update_parametric_representation()
target = context.active_object
if not target:
return
mesh = target.data
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")
@staticmethod
def get_scale_value(system: str, length_unit: str) -> float:
scale_value = 1
if system == "METRIC":
if length_unit == "KILOMETERS":
scale_value /= 1000
elif length_unit == "CENTIMETERS":
scale_value *= 100
elif length_unit == "MILLIMETERS":
scale_value *= 1000
elif length_unit == "MICROMETERS":
scale_value *= 1000000
elif system == "IMPERIAL":
if length_unit == "MILES":
scale_value /= si_conversions["mile"]
elif length_unit == "FEET":
scale_value /= si_conversions["foot"]
elif length_unit == "INCHES":
scale_value /= si_conversions["inch"]
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:
# 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(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:
# snap_to_mesh may return a tuple from the cache, ensure it's a Vector
snapped_pos_vec = Vector(snapped_pos) if not isinstance(snapped_pos, Vector) else snapped_pos
delta = (snapped_pos_vec - 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)
class GizmoMerge(TrisGizmoMixin, bpy.types.Gizmo):
"""Two arrows pointing inward toward each other — conveys joining/merging elements."""
bl_idname = "VIEW3D_GT_merge"
__slots__ = ("custom_shape",)
# Two solid triangles pointing toward the center on the horizontal axis,
# plus two thin tails behind each tip to make them read as arrows rather than
# standalone triangles.
tris = (
# Left arrowhead pointing right (tip at x≈-0.05).
(-0.35, -0.20, 0.0),
(-0.35, 0.20, 0.0),
(-0.05, 0.0, 0.0),
# Left tail behind the arrowhead.
*rect_tris(-0.45, -0.06, -0.30, 0.06),
# Right arrowhead pointing left (tip at x≈0.05).
(0.35, -0.20, 0.0),
(0.35, 0.20, 0.0),
(0.05, 0.0, 0.0),
# Right tail behind the arrowhead.
*rect_tris(0.30, -0.06, 0.45, 0.06),
)
class GizmoSplit(TrisGizmoMixin, bpy.types.Gizmo):
"""Two arrows pointing outward away from each other — conveys splitting/cutting
one element into two. Visual inverse of :class:`GizmoMerge`."""
bl_idname = "VIEW3D_GT_split"
__slots__ = ("custom_shape",)
# Two solid triangles pointing OUTWARD on the horizontal axis (tips at x=±0.35),
# with tails extending toward the centerline. The tails meet at center to form a
# short horizontal bar, suggesting the split point itself.
tris = (
# Left arrowhead pointing left (tip at x=-0.35).
(-0.05, -0.20, 0.0),
(-0.05, 0.20, 0.0),
(-0.35, 0.0, 0.0),
# Left tail extending toward the right (away from the tip, toward center).
*rect_tris(-0.05, -0.06, 0.10, 0.06),
# Right arrowhead pointing right (tip at x=0.35).
(0.05, -0.20, 0.0),
(0.05, 0.20, 0.0),
(0.35, 0.0, 0.0),
# Right tail extending toward the left.
*rect_tris(-0.10, -0.06, 0.05, 0.06),
)
class GizmoExtend(TrisGizmoMixin, bpy.types.Gizmo):
"""An arrow pointing into a vertical bar — conveys extending an element to a target
line (e.g. extending a wall to the 3D cursor)."""
bl_idname = "VIEW3D_GT_extend"
__slots__ = ("custom_shape",)
# Layout: thick vertical bar at the right edge (the "target") with a horizontal
# arrow pointing into it from the left.
tris = (
# Vertical target bar (x = 0.25 to 0.35, full height).
*rect_tris(0.25, -0.30, 0.35, 0.30),
# Arrowhead pointing right toward the bar (tip at x=0.20).
(-0.05, -0.18, 0.0),
(-0.05, 0.18, 0.0),
(0.20, 0.0, 0.0),
# Tail extending leftward from the arrowhead base.
*rect_tris(-0.35, -0.06, -0.05, 0.06),
)
class GizmoExtendVertical(TrisGizmoMixin, bpy.types.Gizmo):
"""Vertical sibling of :class:`GizmoExtend` — arrow pointing UP into a horizontal
bar. Conveys extending an element's height to a target Z."""
bl_idname = "VIEW3D_GT_extend_vertical"
__slots__ = ("custom_shape",)
# Mechanically derived from GizmoExtend by reflecting across Y=X.
tris = swap_xy_tris(GizmoExtend.tris)
def _offset_baseline_tris(mark_x: float) -> tuple[tuple[float, float, float], ...]:
"""Shared geometry for the three offset-baseline icons: a horizontal "wall
section" bar with a vertical mark at ``mark_x`` indicating where the reference
axis sits within the wall thickness. Matches the visual convention used in the
Bonsai N-panel's wall Align row."""
return rect_tris(-0.25, -0.07, 0.25, 0.07) + rect_tris(mark_x - 0.04, -0.22, mark_x + 0.04, 0.22)
class GizmoOffsetExterior(TrisGizmoMixin, bpy.types.Gizmo):
"""Wall offset baseline indicator — reference axis at the exterior face (left mark)."""
bl_idname = "VIEW3D_GT_offset_exterior"
__slots__ = ("custom_shape",)
tris = _offset_baseline_tris(-0.24)
class GizmoOffsetCenter(TrisGizmoMixin, bpy.types.Gizmo):
"""Wall offset baseline indicator — reference axis at the centreline (middle mark)."""
bl_idname = "VIEW3D_GT_offset_center"
__slots__ = ("custom_shape",)
tris = _offset_baseline_tris(0.0)
class GizmoOffsetInterior(TrisGizmoMixin, bpy.types.Gizmo):
"""Wall offset baseline indicator — reference axis at the interior face (right mark)."""
bl_idname = "VIEW3D_GT_offset_interior"
__slots__ = ("custom_shape",)
tris = _offset_baseline_tris(0.24)
class GizmoAddOpening(TrisGizmoMixin, bpy.types.Gizmo):
"""A rectangular frame (square outline with a hole in the middle) — conveys adding an
opening (window/door/void) to a wall."""
bl_idname = "VIEW3D_GT_add_opening"
__slots__ = ("custom_shape",)
# Outer 0.40 × 0.40 square with a 0.25 × 0.25 inner hole, drawn as four bars
# forming a frame, plus a small "+" in the inner hole to convey "add".
tris = (
*rect_tris(-0.20, 0.125, 0.20, 0.20), # Top bar
*rect_tris(-0.20, -0.20, 0.20, -0.125), # Bottom bar
*rect_tris(-0.20, -0.125, -0.125, 0.125), # Left bar
*rect_tris(0.125, -0.125, 0.20, 0.125), # Right bar
*rect_tris(-0.07, -0.015, 0.07, 0.015), # "+" horizontal stroke
*rect_tris(-0.015, -0.07, 0.015, 0.07), # "+" vertical stroke
)
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",
"_display_value", # Actual value for display (can be negative)
"text_offset_sign", # -1 to offset text below/left, +1 for above/right (default)
"show_start_arrow", # Whether to show arrow at start (origin) of dimension
"show_end_arrow", # Whether to show arrow at end of dimension
"text_alignment", # TextAlignment enum: CENTER (default) or START (left-aligned at offset from line)
"_original_value", # Original property value before interaction
"_click_offset", # Offset from dimension tip to click position (for snap correction)
"show_extension_lines", # Whether to show extension lines at dimension endpoints
"text_formatter", # Optional (props, value) -> str to override the default dimension label
)
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._display_value = 1.0
self.text_offset_sign = 1
self.show_start_arrow = False
self.show_end_arrow = True
self.text_alignment = TextAlignment.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
display_value = getattr(self, "_display_value", self._dimension_length)
text_formatter = getattr(self, "text_formatter", None)
gizmo_group = getattr(self, "gizmo_group", None)
display_text: str | None = None
if text_formatter is not None and gizmo_group is not None:
obj = bpy.context.active_object
props = gizmo_group.get_props(obj) if obj is not None else None
if props is not None:
display_text = text_formatter(props, display_value)
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", TextAlignment.CENTER),
prop_name=getattr(self, "prop_name", None),
display_value=display_value,
display_text=display_text,
)
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
# Store the actual value for display (can be negative)
self._display_value = max(-10000.0, min(length, 10000.0))
# Clamp to valid range (0 to 10000 meters is reasonable for BIM) for drawing
self._dimension_length = max(0.0, min(abs(length), 10000.0))
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set:
"""Initialize dimension gizmo interaction with click-position tracking.
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 CycleTypeMixin:
"""Mixin for operators that cycle through type literals.
Subclasses must define:
element_checker: Class method name on tool.Blender.Modifier (e.g., "is_door")
props_getter: Method name on tool.Model (e.g., "get_door_props")
type_literal: The type literal from tool.Model (e.g., tool.Model.DoorType)
type_attr: Attribute name on props for the type (e.g., "door_type")
Optional:
skip_element_check: If True, skip the element type validation (default False)
"""
element_checker: str
props_getter: str
type_literal: type
type_attr: str
skip_element_check: bool = False
reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"})
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]:
"""Set reverse direction based on Shift key."""
self.reverse = event.shift
return self.execute(context)
def _cycle_type(self, context: bpy.types.Context) -> set[str]:
"""Common type cycling logic. Call from execute() or _execute()."""
obj = context.active_object
if not obj:
return {"CANCELLED"}
if not self.skip_element_check:
element = tool.Ifc.get_entity(obj)
checker = getattr(tool.Blender.Modifier, self.element_checker)
if not element or not checker(element):
return {"CANCELLED"}
props = getattr(tool.Model, self.props_getter)(obj)
types = get_args(self.type_literal)
current = getattr(props, self.type_attr)
idx = types.index(current) if current in types else 0
direction = -1 if self.reverse else 1
setattr(props, self.type_attr, types[(idx + direction) % len(types)])
return {"FINISHED"}
class BillboardingGizmoGroupMixin:
"""Mixin for standalone ``bpy.types.GizmoGroup`` classes whose icons must billboard
(face the camera) and re-position every frame.
Blender calls ``GizmoGroup.refresh()`` only on state-change events (selection,
property change, dependency update) — not on camera rotation. A gizmo group that
only sets ``matrix_basis`` in ``refresh()`` will appear to "freeze" its rotation
at the camera angle in effect when it was last refreshed; orbiting the camera
leaves the icon facing the wrong way.
``draw_prepare()`` *is* called every redraw, so the fix is to run the same
positioning code from both events. Rather than overriding ``refresh()`` and
``draw_prepare()`` in every gizmo group that has this need, subclass this mixin
and implement a single ``position_gizmos(context)`` method.
Usage::
class MyGizmoGroup(bpy.types.GizmoGroup, BillboardingGizmoGroupMixin):
bl_idname = "..."
...
def setup(self, context):
...
def position_gizmos(self, context):
# set matrix_basis on every gizmo here, using get_billboard_rotation
# for any icon that should face the camera.
...
``position_gizmos`` should be idempotent — it's called twice when a state change
coincides with a redraw (once via ``refresh``, once via ``draw_prepare``)."""
def refresh(self, context: bpy.types.Context) -> None:
self.position_gizmos(context)
def draw_prepare(self, context: bpy.types.Context) -> None:
self.position_gizmos(context)
def setup_icon_gizmo(
self,
gizmo_type: str,
color: tuple[float, float, float],
highlight_color: tuple[float, float, float],
operator: str,
alpha: float = 0.8,
) -> bpy.types.Gizmo:
"""Convenience wrapper over :func:`setup_icon_gizmo` for subclasses."""
return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha)
def position_gizmos(self, context: bpy.types.Context) -> None:
raise NotImplementedError(
f"{type(self).__name__} must implement position_gizmos(context) when using BillboardingGizmoGroupMixin."
)
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
Color Convention
================
Gizmo colors follow Blender's axis convention:
- RED: X-axis dimensions (width)
- GREEN: Y-axis dimensions (depth)
- BLUE: Z-axis dimensions (height)
View Direction API
==================
Use ``ViewDirection.from_context(context, mw)`` to determine camera position::
view = ViewDirection.from_context(context, obj.matrix_world)
if view.from_back: # Camera behind element (interior for doors)
y_pos = props.depth
if view.from_left: # Camera on left side
x_pos = -offset
Negative Value Handling
=======================
Some dimensions support negative values (e.g., lining_offset). When negative,
the gizmo is flipped 180° using FLIP_MATRIX so the arrow points opposite.
"""
# === Gizmo Colors ===
# 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
GIZMO_STACK_OFFSET = 0.1 # Offset increment for stacking multiple gizmos to avoid overlap
GIZMO_CLAMP_MAX = 10000.0 # Maximum value for dimension clamping (meters)
# Pre-computed flip matrix for negative value handling (180° rotation around Z)
FLIP_MATRIX = Matrix.Rotation(math.pi, 4, "Z")
# === Icon Gizmo Layout (meters) ===
# Icons are positioned in a horizontal row above the element:
# [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: GizmoColor | str) -> tuple[float, float, float]:
"""Get color tuple from GizmoColor enum or color name string."""
colors = {
GizmoColor.RED: cls.COLOR_RED,
GizmoColor.GREEN: cls.COLOR_GREEN,
GizmoColor.BLUE: cls.COLOR_BLUE,
}
if isinstance(color, GizmoColor):
return colors.get(color, cls.COLOR_RED)
# Handle legacy string input
return colors.get(GizmoColor(color.upper()), cls.COLOR_RED)
@classmethod
def get_arrow_color_from_axis(cls, axis: tuple[int, int, int]) -> tuple[float, float, float]:
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.
Note: Consider using ViewDirection.from_context() for a cleaner API.
"""
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_view_direction(self, context: bpy.types.Context, world_matrix: Matrix) -> "ViewDirection":
"""Get view direction as a ViewDirection object for cleaner API.
Example:
view = self.get_view_direction(context, mw)
if view.from_back:
y_pos = props.depth
else:
y_pos = 0
"""
from_neg_y, from_neg_x = self.get_local_view_direction(context, world_matrix)
return ViewDirection(from_negative_y=from_neg_y, from_negative_x=from_neg_x)
def update_gizmo_visibility(self, gizmo: bpy.types.Gizmo, is_editing: bool, pref_enabled: bool) -> bool:
"""Update gizmo visibility based on modal state, editing state, and preference.
Consolidates the common pattern:
if hidden_by_modal:
gizmo.hide = True
else:
gizmo.hide = not is_editing or not pref_enabled
Args:
gizmo: The gizmo to update visibility for
is_editing: Whether the element is currently being edited
pref_enabled: Whether this gizmo type is enabled in preferences
Returns:
True if the gizmo is now visible (not hidden), False otherwise
"""
if self.is_gizmo_hidden_by_modal(gizmo):
gizmo.hide = True
return False
gizmo.hide = not is_editing or not pref_enabled
return not gizmo.hide
def get_y_position_for_view(
self, props, viewing_from_negative_y: bool, width_attr: str = "width", use_offset: bool = False
) -> float:
"""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
@staticmethod
def get_camera_facing_outer_y(
viewing_from_negative_y: bool,
near_y: float,
far_y: float,
gizmo_offset: float = 0.0,
) -> float:
"""Y coordinate just outside the camera-facing face of an element.
Generalises :meth:`get_y_position_for_view` for elements whose near face
isn't at the local origin. ``near_y`` is the local-Y of the -Y face;
``far_y`` is the local-Y of the +Y face. Returns the Y just *outside* the
face the camera is currently looking at, pushed by ``gizmo_offset`` (use
``cls.GIZMO_OFFSET`` for the standard handle gap).
Suits walls (``near_y = props.offset``, ``far_y = props.offset + props.thickness``)
and any other element whose section sits inside a non-zero Y band. Stair /
door / window can also call this once their callers pass explicit near/far
instead of the implicit ``width_attr`` pattern, eliminating
``get_y_position_for_view``, ``get_lining_y_position_for_view`` etc. as
wrappers around the same shape — but they're left intact for now to avoid
churning code paths that already work."""
if viewing_from_negative_y:
return near_y - gizmo_offset
return far_y + gizmo_offset
def get_icon_y_for_view(self, props, viewing_from_negative_y: bool) -> float:
"""Get Y position for editing icons based on view direction.
Similar to get_y_position_for_view but always includes offset and
uses the element's furthest Y extent for positioning.
Args:
props: Element properties object
viewing_from_negative_y: True if viewing from -Y side
Returns:
Y position for icon row: -GIZMO_OFFSET when viewing from -Y,
width + GIZMO_OFFSET otherwise
"""
width = getattr(props, "width", 0)
if viewing_from_negative_y:
return -self.GIZMO_OFFSET
return width + self.GIZMO_OFFSET
def compose_gizmo_matrix(self, translation: Vector, axis: tuple[int, int, int]) -> Matrix:
"""Compose a gizmo transformation matrix from translation and axis.
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)
def get_x_positions_for_view(
self, width: float, offset: float, viewing_from_negative_x: bool
) -> tuple[float, float]:
"""Get X positions for height and lining gizmos based on view direction.
When viewing from -X side, height goes to -X and lining goes to +X.
When viewing from +X side, height goes to +X and lining goes to -X.
Args:
width: Element width (e.g., overall_width)
offset: Additional offset (e.g., casing_thickness)
viewing_from_negative_x: True if viewing from -X side
Returns:
Tuple of (x_pos_height, x_pos_lining)
"""
if viewing_from_negative_x:
x_pos_height = -offset - self.GIZMO_OFFSET
x_pos_lining = width + offset + self.GIZMO_OFFSET
else:
x_pos_height = width + offset + self.GIZMO_OFFSET
x_pos_lining = -offset - self.GIZMO_OFFSET
return x_pos_height, x_pos_lining
def get_dimension_matrix_lining_offset_default(self, props) -> Matrix:
"""Default lining offset matrix for door/window elements.
Position at element width + offset, at Y=0, below the element.
Override in subclass if different positioning is needed.
"""
width = getattr(props, "overall_width", 0)
return self.compose_gizmo_matrix(Vector((width + self.GIZMO_OFFSET, 0, -self.GIZMO_OFFSET)), (0, 1, 0))
def get_casing_offset(self, props) -> float:
"""Get casing offset for view-dependent dimension positioning.
Override in door to return casing_thickness when lining_offset is 0.
Default returns 0 (no casing offset).
"""
return 0.0
def _update_view_dependent_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None: # noqa: ARG002
"""Update overall_width, overall_height, and lining_offset based on view direction.
This base implementation handles the common pattern for door/window gizmos.
Subclasses can override get_casing_offset() to customize behavior.
"""
viewing_from_negative_y, viewing_from_negative_x = self._frame_view_dir
y_pos = self.get_lining_y_position_for_view(props, viewing_from_negative_y)
self.set_dimension_gizmo_position("overall_width", mw, Vector((0, y_pos, -self.GIZMO_OFFSET)), (1, 0, 0))
casing_offset = self.get_casing_offset(props)
x_pos_height, x_pos_lining = self.get_x_positions_for_view(
props.overall_width, casing_offset, viewing_from_negative_x
)
self.set_dimension_gizmo_position("overall_height", mw, Vector((x_pos_height, y_pos, 0)), (0, 0, 1))
self.set_dimension_gizmo_position(
"lining_offset", mw, Vector((x_pos_lining, 0, -self.GIZMO_OFFSET)), (0, 1, 0), props.lining_offset
)
def create_icon_gizmo(
self,
gizmo_type: str,
color: tuple[float, float, float],
operator: str,
prop_path: str | None = None,
alpha: float = 0.8,
**operator_props,
) -> bpy.types.Gizmo:
"""Create an icon gizmo with common settings.
Args:
gizmo_type: Blender gizmo type (e.g., "VIEW3D_GT_lock", "VIEW3D_GT_plus")
color: RGB color tuple
operator: Operator ID to trigger (e.g., "bim.toggle_stair_property")
prop_path: Optional property path for lock icons (e.g., "BIMStairProperties.lock")
alpha: Opacity (default 0.8)
**operator_props: Additional operator properties to set
Returns:
The created gizmo
"""
prefs = tool.Blender.get_addon_preferences()
highlight_color = prefs.decorator_color_selected[:3]
gz = self.gizmos.new(gizmo_type)
gz.use_draw_scale = False
gz.color = color
gz.color_highlight = highlight_color
gz.alpha = alpha
if prop_path:
gz.prop_path = prop_path
op = gz.target_set_operator(operator)
for key, value in operator_props.items():
setattr(op, key, value)
return gz
def create_arc_gizmo(
self,
color: tuple[float, float, float],
operator: str,
prop_path: str | None = None,
alpha: float = 0.5,
**operator_props,
) -> bpy.types.Gizmo:
"""Create an arc gizmo for swing/rotation indicators (e.g., door swing).
Args:
color: RGB color tuple
operator: Operator ID to trigger (e.g., "bim.toggle_door_swing")
prop_path: Optional property path (e.g., "BIMDoorProperties.door_type")
alpha: Opacity (default 0.5 for arc gizmos)
**operator_props: Additional operator properties to set
Returns:
The created arc gizmo
"""
return self.create_icon_gizmo("VIEW3D_GT_arc", color, operator, prop_path, alpha, **operator_props)
@classmethod
def is_element_type(cls, element) -> bool:
raise NotImplementedError("Subclass must implement is_element_type()")
@classmethod
def poll(cls, context) -> bool:
obj = tool.Blender.get_active_object(is_selected=True)
if obj is None:
return False
if not tool.Blender.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport:
return False
if len(tool.Blender.get_selected_objects()) != 1:
return False
element = tool.Ifc.get_entity(obj)
return bool(element) and cls.is_element_type(element)
def setup(self, context: bpy.types.Context) -> None:
"""Template method for gizmo setup.
Subclasses should override setup_element_specific_gizmos() to add
element-specific gizmos (e.g., door swing arcs, stair lock icons).
"""
self.setup_editing_gizmos(context)
self.setup_dimension_gizmos(context)
self.setup_element_specific_gizmos(context)
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
"""Override to add element-specific gizmos.
Called after setup_editing_gizmos and setup_dimension_gizmos.
Examples: door swing arcs, stair lock/plus/minus icons.
"""
pass
# Frame-scoped caches populated by :meth:`_prime_frame_caches` at the top of
# ``refresh()`` and ``draw_prepare()``. Every per-frame helper — preferences
# access, view-direction lookup, billboard rotation — reads these instead of
# re-deriving the same values, since each gizmo group ends up needing them
# 25× per frame across its position helpers.
_frame_prefs: Any = None
_frame_view_dir: tuple[bool, bool] | None = None
_frame_billboard_rot: "Matrix | None" = None
def _prime_frame_caches(self, context: bpy.types.Context, mw: "Matrix") -> None:
self._frame_prefs = tool.Blender.get_addon_preferences()
self._frame_view_dir = self.get_local_view_direction(context, mw)
self._frame_billboard_rot = get_billboard_rotation(context)
def refresh(self, context: bpy.types.Context) -> None:
"""Template method for gizmo refresh.
Subclasses should override _refresh_element_specific() for element-specific updates
(e.g., door swing arcs, stair lock icons).
"""
if not self.is_setup_complete():
return
obj = context.active_object
if not obj:
return
props = self.get_props(obj)
mw = obj.matrix_world
self._prime_frame_caches(context, mw)
self.update_editing_gizmos(context, mw, props)
self.update_dimension_gizmos(mw, props)
self._refresh_element_specific(context, mw, props)
def _refresh_element_specific(self, context: bpy.types.Context, mw: "Matrix", props) -> None: # noqa: ARG002
"""Override for element-specific refresh logic.
Called from both refresh() (on state change) and draw_prepare() (per frame),
so any override must be idempotent and cheap. Use this to re-position or
re-billboard element-specific gizmos (door swing arcs, stair lock/+/- icons,
wall cursor icons, etc.).
"""
pass
# Subclass should define these class attributes for metadata-driven dispatch
# If not defined, subclass must override get_props() and get_gizmo_prefs()
props_getter: str | None = None # e.g., "get_door_props"
gizmo_pref_name: str | None = None # e.g., "door"
def get_props(self, obj: bpy.types.Object) -> Any:
"""Get properties for the element.
Subclass can either:
1. Define class attribute `props_getter` (e.g., "get_door_props")
2. Override this method directly
"""
if self.props_getter:
return getattr(tool.Model, self.props_getter)(obj)
raise NotImplementedError("Subclass must define props_getter or override get_props()")
def get_addon_prefs(self):
"""Return the addon preferences struct. Inside ``refresh`` / ``draw_prepare``
the frame cache is hit; outside (e.g. ``setup``) we fall through to a fresh
lookup so callers don't have to know which call path they're on."""
return self._frame_prefs if self._frame_prefs is not None else tool.Blender.get_addon_preferences()
def get_decoration_colors(self) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Get default and highlight colors from preferences.
Returns:
Tuple of (default_color, highlight_color) as RGB tuples.
"""
prefs = self.get_addon_prefs()
return prefs.decorations_colour[:3], prefs.decorator_color_selected[:3]
def get_gizmo_prefs(self) -> Any:
"""Get gizmo preferences for this element type.
Subclass can either:
1. Define class attribute `gizmo_pref_name` (e.g., "door")
2. Override this method directly
"""
if self.gizmo_pref_name:
prefs = self.get_addon_prefs()
return getattr(prefs.gizmos, self.gizmo_pref_name)
raise NotImplementedError("Subclass must define gizmo_pref_name or override get_gizmo_prefs()")
def is_setup_complete(self) -> bool:
"""Check if gizmo setup has been completed.
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],
value: float | None = None,
) -> 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)
value: Optional value to check for negative flip. If None, no flip is applied.
"""
if gz := self.get_dimension_gizmo_if_visible(attr_name):
self._apply_dimension_matrix(gz, mw, self.compose_gizmo_matrix(position, axis), value)
def _apply_dimension_matrix(
self,
gizmo: bpy.types.Gizmo,
mw: Matrix,
base_matrix: Matrix,
value: float | None = None,
) -> None:
"""Apply matrix to dimension gizmo, flipping for negative values.
Consolidates negative value handling in one place. For negative values,
the gizmo is rotated 180° around Z so the dimension arrow points in the
opposite direction while keeping the origin at the same position.
Args:
gizmo: The dimension gizmo to update
mw: Object's world matrix
base_matrix: Local transformation matrix
value: If negative, applies FLIP_MATRIX rotation
"""
if value is not None and value < 0:
gizmo.matrix_basis = mw @ base_matrix @ self.FLIP_MATRIX
else:
gizmo.matrix_basis = mw @ base_matrix
def should_hide_dimension_gizmo(
self, gizmo: bpy.types.Gizmo, config: "DimensionGizmoConfig", props, gizmo_prefs
) -> 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_icon_gizmo(
self,
gizmo_type: str,
color: tuple[float, float, float],
operator: str,
highlight_color: tuple[float, float, float] | None = None,
alpha: float = 0.8,
) -> bpy.types.Gizmo:
"""Create and configure an icon gizmo with standard settings.
Thin wrapper over :func:`setup_icon_gizmo` that defaults ``highlight_color``
to the addon-prefs selection color via ``get_decoration_colors``.
"""
if highlight_color is None:
_, highlight_color = self.get_decoration_colors()
return setup_icon_gizmo(self, gizmo_type, color, highlight_color, operator, alpha)
def setup_editing_gizmos(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
self.pen_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_pen", default_color, self.enable_editing_operator, highlight_color
)
self.validate_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_validate", self.COLOR_GREEN, self.finish_editing_operator, highlight_color
)
self.cancel_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_cancel", self.COLOR_RED, self.cancel_editing_operator, highlight_color
)
if self.cycle_type_operator:
self.cycle_gizmo = self._setup_icon_gizmo(
"VIEW3D_GT_cycle", default_color, self.cycle_type_operator, highlight_color
)
def _make_dimension_getter(self, config: DimensionGizmoConfig):
"""Create getter closure for dimension gizmo."""
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.text_formatter = config.text_formatter
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
# Priority: config.matrix_position > get_dimension_matrix_* method > Identity.
if config.matrix_position:
base_matrix = self.compose_gizmo_matrix(config.matrix_position(props), config.axis)
else:
matrix_method = getattr(self, f"get_dimension_matrix_{config.attr_name}", None)
base_matrix = matrix_method(props) if matrix_method else Matrix.Identity(4)
if config.compute_value:
value = config.compute_value(props)
else:
value = getattr(props, config.attr_name, 0.0)
# Use consolidated negative value handling
self._apply_dimension_matrix(gizmo, mw, base_matrix, value)
gizmo.show_start_arrow = config.show_start_arrow
gizmo.show_end_arrow = config.show_end_arrow
gizmo.set_dimension_length(value)
def get_icon_y_extent(self, props) -> tuple[float, float]:
"""Get Y extents for icon positioning based on element geometry.
Subclasses should override this to return the furthest geometry extents
in the +Y and -Y directions from the element origin.
Returns:
Tuple of (positive_y_extent, negative_y_extent).
Both values should be positive (absolute distances).
The base implementation returns (0, 0).
Example for a door:
return (lining_offset + lining_depth + 2*OFFSET, 2*OFFSET)
"""
return (0.0, 0.0)
def get_icon_y_offset(self, context: bpy.types.Context, mw: Matrix) -> float: # noqa: ARG002
"""Get Y offset for icons based on view direction.
Uses get_icon_y_extent() to determine how far to offset icons based on
the camera viewing direction. Icons are positioned beyond the geometry
on the side the camera is viewing from.
Subclasses typically only need to override get_icon_y_extent().
"""
obj = context.active_object
if not obj:
return self.ICON_Y_OFFSET
props = self.get_props(obj)
positive_extent, negative_extent = self.get_icon_y_extent(props)
if self._frame_view_dir[0]:
return -negative_extent
return positive_extent
def update_editing_gizmos(self, context: bpy.types.Context, mw: Matrix, props) -> None:
"""Update editing icon gizmo positions to billboard toward camera."""
icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET
icon_y = self.get_icon_y_offset(context, mw)
billboard_rot = self._frame_billboard_rot
# 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, dimension gizmos, and element-specific
gizmos. Subclasses can override _update_dimension_gizmo_positions() to
customize dimension gizmo positioning, and _refresh_element_specific() to
re-billboard element-specific gizmos per frame.
"""
obj = context.active_object
if not obj:
return
props = self.get_props(obj)
mw = obj.matrix_world
self._prime_frame_caches(context, mw)
self.update_editing_gizmos(context, mw, props)
# `update_dimension_gizmos` flips the dimension gizmos' `hide` flag
# based on `props.is_editing` + per-config visibility conditions.
# `refresh()` already calls it, but `refresh()` only fires on depsgraph
# events — a `finish_editing_*` operator that toggles `is_editing` to
# False without mutating IFC (e.g. wall no-op commit, cancel) does not
# trigger a depsgraph update, so without this call the dimension gizmos
# would stay visible until the next user input.
self.update_dimension_gizmos(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)
self._refresh_element_specific(context, mw, props)
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
"""