mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-09 21:53:40 +00:00
fb70c64138
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.
5187 lines
196 KiB
Python
5187 lines
196 KiB
Python
# 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
|
||
# 2–5× 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
|
||
"""
|