diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 8b10314faa..9cda778cc1 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -138,15 +138,24 @@ classes = ( gizmos.GizmoArrow2D, gizmos.GizmoCone, gizmos.GizmoDimension, - gizmos.GizmoLock, + gizmos.GizmoLockOpen, + gizmos.GizmoLockClosed, gizmos.GizmoArc, + gizmos.GizmoFillet, + gizmos.GizmoWallCornerIcon, + gizmos.GizmoWallTeeIcon, gizmos.GizmoPen, gizmos.GizmoValidate, gizmos.GizmoCancel, gizmos.GizmoPlus, gizmos.GizmoMinus, + gizmos.GizmoTrash, + gizmos.GizmoArrayParent, + gizmos.GizmoArrayAll, + gizmos.GizmoArrayLayerIndicator, gizmos.GizmoMerge, gizmos.GizmoSplit, + gizmos.GizmoUnjoin, gizmos.GizmoExtend, gizmos.GizmoExtendVertical, gizmos.GizmoOffsetExterior, @@ -154,6 +163,7 @@ classes = ( gizmos.GizmoOffsetInterior, gizmos.GizmoAddOpening, gizmos.GizmoCycle, + gizmos.GizmoMenu, # Drawing-specific gizmos gizmos.UglyDotGizmo, gizmos.ExtrusionGuidesGizmo, diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 31a0be5c80..55096bc6a0 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -19,73 +19,13 @@ # # This file was modified with the assistance of an AI coding tool. -""" -Gizmo infrastructure for parametric BIM element editing. +"""Viewport gizmos 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. +Feature gizmo groups (one per parametric type) declare their gizmos via +``DimensionGizmoConfig`` and inherit shared setup / refresh / snapping +machinery from ``BaseParametricGizmoGroup``. Single-click icons bind to +operators via ``target_set_operator``; drag handles inherit modal state +from ``GizmoMovable``. """ __all__ = [ # noqa: RUF022 (unsorted `__all__`) @@ -95,7 +35,6 @@ __all__ = [ # noqa: RUF022 (unsorted `__all__`) "CoordinateSpace", "ModalState", "DimensionGizmoConfig", - "DimensionDrawConfig", "ViewDirection", "GizmoModalContext", "get_modal_context", @@ -114,20 +53,24 @@ __all__ = [ # noqa: RUF022 (unsorted `__all__`) "create_circle_arc", "BIM_OT_gizmo_value_input", "GizmoMovable", - "GizmoLock", + "GizmoLockOpen", + "GizmoLockClosed", "GizmoArc", "GizmoPen", "GizmoValidate", "GizmoCancel", "GizmoPlus", "GizmoMinus", + "GizmoArrayParent", + "GizmoArrayAll", + "GizmoArrayLayerIndicator", "GizmoCycle", + "GizmoMenu", "GizmoArrow", "GizmoArrow2D", "GizmoCone", "GizmoDimension", "DimensionRenderer", - "CycleTypeMixin", "BaseParametricGizmoGroup", "UglyDotGizmo", "ExtrusionGuidesGizmo", @@ -138,11 +81,12 @@ 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 +from typing import Any, ClassVar, Literal, Protocol, runtime_checkable import blf import bpy import gpu +import ifcopenshell.util.element import numpy as np from bpy import types from bpy_extras import view3d_utils @@ -160,6 +104,16 @@ from mathutils.kdtree import KDTree import bonsai.tool as tool from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader +# Backward-compat re-exports — these mixins moved to bim.parametric_lifecycle +# in the gizmos.py framework refactor. PR4 callers (CycleDoorType / CycleWindowType +# / CycleStairType) still spell gizmo.CycleTypeMixin; the re-export keeps the +# old access path alive until PR4 rewrites the import. PR5 cleanup drops these. +from bonsai.bim.parametric_lifecycle import ( # noqa: F401, E402 + CycleTypeMixin, + PickTypeMixin, + TypeAccessorBase, +) + SNAP_POINT_SIZE = 10.0 SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0) SNAP_MAX_RADIUS = 50.0 @@ -181,6 +135,14 @@ CONE_SEGMENTS = 16 ARC_SEGMENTS = 24 ARC_LINE_WIDTH = 0.015 +# Door-swing arc: start a couple of degrees off the jamb so the arc tip stays +# visible; full quarter-turn for the standard 90-degree swing. +DOOR_SWING_ANGLE_MIN = 2.0 +DOOR_SWING_ANGLE_MAX = 90.0 + +# Default scale factor for billboarded icons (Blender-unit visual size). +DEFAULT_BILLBOARD_SCALE = 0.5 + PRECISION_MODE_MULTIPLIER = 0.1 RAY_CAST_DISTANCE = 1000 @@ -309,9 +271,9 @@ class ModalState(Enum): 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. + Passes state between a gizmo and the BIM_OT_gizmo_value_input modal operator. + Blender ID properties cannot carry function callbacks, so a module-level + instance carries them out-of-band. Attributes: move_set_cb: Callback to set the property value @@ -470,9 +432,8 @@ class GPUStateScope: 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. + Manages text drawing operations including value text, property + tooltips, and text backgrounds. Usage: renderer = DimensionTextRenderer.get_instance() @@ -626,50 +587,6 @@ class DimensionTextRenderer: 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. @@ -738,20 +655,31 @@ class ViewDirection: ) +# Eight unit-length directions for the multi-pass outline shared by every +# icon-class gizmo and by ``DimensionRenderer``'s arrowhead halo. The +# silhouette is rendered once per direction, offset by an outline width +# along that direction; the union approximates a circular dilation — +# a uniform halo on every side. Cardinals are length 1; diagonals use +# sqrt(0.5) components so every direction is at the same Euclidean +# distance from the origin. Uniform scaling around the local origin +# can't replace this: for asymmetric / multi-part geometry it just pushes +# parts further from the origin, which reads as a directional shift +# rather than an outline. +_OUTLINE_DIRECTIONS_8 = ( + (1.0, 0.0), + (-1.0, 0.0), + (0.0, 1.0), + (0.0, -1.0), + (0.7071067811865476, 0.7071067811865476), + (-0.7071067811865476, 0.7071067811865476), + (0.7071067811865476, -0.7071067811865476), + (-0.7071067811865476, -0.7071067811865476), +) + + 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, ...) - """ + """Singleton renderer for dimension line graphics. Draws the dimension + line, end arrows, and extension lines in screen space.""" _instance: "DimensionRenderer | None" = None _line_shader = None @@ -762,6 +690,15 @@ class DimensionRenderer: EXTENSION_LENGTH = 4 LINE_WIDTH = 2.0 MIN_PIXELS_FOR_DETAILS = 35 + # Outline underlay so the dimension stays legible against same-color + # backgrounds (white line on white wall). The line uses a single wider + # dark pass (one extra pixel on each side); the arrowheads use the same + # 8-direction halo technique as icon-class gizmos because a uniform + # widening of a triangle is shape-dependent, not a uniform halo. + OUTLINE_LINE_WIDTH_INCREASE = 2.0 + OUTLINE_LINE_ALPHA = 0.7 + OUTLINE_ARROW_PX = 1.5 + OUTLINE_ARROW_ALPHA = 0.4 @classmethod def get_instance(cls) -> "DimensionRenderer": @@ -917,26 +854,40 @@ class DimensionRenderer: vertices.append(ext_end_bottom) indices.append((idx, idx + 1)) + # Force the main pass fully opaque so the dark outline underlay + # doesn't bleed through and grey out the line/arrows. if is_highlight: - draw_color = (*highlight_color, highlight_alpha) + draw_color = (*highlight_color, 1.0) else: - draw_color = (*color, alpha) + draw_color = (*color, 1.0) 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) + # Underlay for legibility against same-colour backgrounds. + shader.uniform_float("lineWidth", self.LINE_WIDTH + self.OUTLINE_LINE_WIDTH_INCREASE) + shader.uniform_float("color", (0.0, 0.0, 0.0, self.OUTLINE_LINE_ALPHA)) + line_batch.draw(shader) + shader.uniform_float("lineWidth", self.LINE_WIDTH) + shader.uniform_float("color", draw_color) 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}) + # Same eight-direction halo as the icon mixin, in screen-pixel units. + tri_shader.uniform_float("color", (0.0, 0.0, 0.0, self.OUTLINE_ARROW_ALPHA)) + for dx, dy in _OUTLINE_DIRECTIONS_8: + with gpu.matrix.push_pop(): + gpu.matrix.multiply_matrix( + Matrix.Translation((dx * self.OUTLINE_ARROW_PX, dy * self.OUTLINE_ARROW_PX, 0.0)) + ) + tri_batch.draw(tri_shader) + tri_shader.uniform_float("color", draw_color) tri_batch.draw(tri_shader) if length_screen >= self.MIN_PIXELS_FOR_DETAILS: @@ -1078,12 +1029,14 @@ class ParametricProps(Protocol): @dataclass(slots=True) -class DimensionGizmoConfig: - """Configuration for a dimension gizmo. +class BaseValueGizmoConfig: + """Shared scaffolding for every parametric value gizmo (dimensions, counts, …). - 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. + Holds the attribute binding, axis/placement hints, color, and read/write hooks + that any value-driven gizmo declared on a ``BaseParametricGizmoGroup`` needs. + Continuous-distance specifics (arrows, text alignment, snap scaling) belong on + ``DimensionGizmoConfig``; integer-stepper specifics belong on the future + ``CountGizmoConfig`` sibling. Color and prop_name are auto-derived if not specified: - axis (1,0,0) or (-1,0,0) -> RED @@ -1091,6 +1044,141 @@ class DimensionGizmoConfig: - axis (0,0,1) or (0,0,-1) -> BLUE - prop_name: "attr_name" -> "Attr Name" (underscores to spaces, title case) + Attributes: + attr_name: Property name to bind to (e.g., "overall_width"). Used to generate + the per-gizmo attribute on the gizmo group. + axis: Direction tuple (x, y, z). Determines color if not specified and defines + the drag/orientation 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. + compute_value: Optional function(props) -> value for computed values. + If None, reads directly from getattr(props, attr_name). + apply_value: Optional function(props, value) to apply new values after edit. + 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. + The returned Vector is the local-space position where the gizmo origin + will be placed. Combined with axis to create the full transformation matrix. + """ + + 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 + compute_value: Callable[[Any], Any] | None = None + apply_value: Callable[[Any, Any], None] | None = None + visibility_condition: Callable[[Any], bool] | None = None + # Optional: function(props) -> Vector position. + # + # SUBTLE: presence of this callable doubles as a *trigger* in + # ``BaseParametricGizmoGroup.update_dimension_gizmos`` — when set, the + # gizmo's per-frame matrix is composed via ``compose_gizmo_matrix``, + # which calls ``get_axis_rotation_matrix(self.axis)`` to align the + # gizmo's intrinsic +X direction with ``self.axis`` in object-local + # space. When this is None, the framework falls back to + # ``base_matrix = Identity`` (no axis rotation), and the dimension's + # visual line renders along the object's local +X regardless of + # ``self.axis``. If your dimension's axis is not local +X, you MUST + # pass a ``matrix_position`` callable — even ``lambda _props: Vector((0, 0, 0))`` + # is enough to flip the branch. The wall pattern uses + # ``set_dimension_gizmo_position`` for this; the declarative pattern + # uses ``matrix_position`` for the same effect. + matrix_position: Callable[[Any], "Vector"] | None = None + + 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 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() + + +@dataclass(slots=True) +class CountGizmoConfig(BaseValueGizmoConfig): + """Configuration for an integer-stepper gizmo (drag-snap-to-int handle). + + Renders as a fixed-size bar (no arrows, no extension lines) with the integer + value as text. Built on top of ``BIM_GT_gizmo_dimension`` — the underlying + gizmo type is reused; only the configuration differs (arrows/extension + lines off, fixed visual length, ``move_set_cb`` wrapped to snap-to-int and + clamp to [min_count, max_count]). + + Examples: + # Basic count - simple integer stepper bound to props.count + CountGizmoConfig( + attr_name="count", + axis=(1, 0, 0), + min_count=1, + max_count=999, + ) + + # With keyboard sensitivity tuning - drag 1m → count += 5 + CountGizmoConfig( + attr_name="count", + axis=(1, 0, 0), + delta_scale=5.0, + ) + + See ``BaseValueGizmoConfig`` for the shared attributes (attr_name, axis, + color, prop_name, compute_value, apply_value, visibility_condition, + matrix_position). + + Count-specific attributes: + min_count: Minimum allowed value when dragging (default 1). + max_count: Maximum allowed value when dragging (default 999). + step: Integer step size; drag values round to nearest multiple of step. + delta_scale: Drag-to-count multiplier. Higher = more counts per meter + of drag. Default 2.0 = roughly half a count per metre, tuned so a + short flick covers small counts without overshoot. + count_formatter: Optional function(props, value) -> str for the count + label. If None, falls back to ``str(int(value))``. + """ + + min_count: int = 1 + max_count: int = 999 + step: int = 1 + delta_scale: float = 2.0 + count_formatter: Callable[[Any, int], str] | None = None + + def __post_init__(self): + BaseValueGizmoConfig.__post_init__(self) + if self.min_count > self.max_count: + raise ValueError(f"min_count {self.min_count} must be <= max_count {self.max_count}") + if self.step < 1: + raise ValueError(f"step must be >= 1, got {self.step}") + + +@dataclass(slots=True) +class DimensionGizmoConfig(BaseValueGizmoConfig): + """Configuration for a continuous-float dimension line 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. + Examples: # Basic dimension - uses attr_name to read/write property DimensionGizmoConfig( @@ -1114,31 +1202,23 @@ class DimensionGizmoConfig: 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). + See ``BaseValueGizmoConfig`` for the shared attributes (attr_name, axis, color, + prop_name, compute_value, apply_value, visibility_condition, matrix_position). + + Dimension-specific attributes: + min_value: Lower bound the default ``attr_name`` setter clamps to + before writing (default 0.0 — the floor for natural non-negative + dimensions like ``wall_thickness``, ``casing_thickness``, + ``overall_width``). Only consulted when ``apply_value`` is None; + when a custom ``apply_value`` is supplied, the callback owns any + bounding (it can pass through, absolutise, or reproject the sign + as needed). 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 @@ -1148,10 +1228,6 @@ class DimensionGizmoConfig: `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 @@ -1159,22 +1235,15 @@ class DimensionGizmoConfig: 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 + schematic_visible_length: float | None = None # Override the schematic group's default tag length for this dim. + # In-place dimensions ignore this — it only affects schematic-group rendering. 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") + # @dataclass(slots=True) rebinds the class in module namespace, leaving super()'s + # implicit __class__ cell pointing at the pre-decorator class. Call the parent + # __post_init__ directly to avoid the resulting TypeError. + BaseValueGizmoConfig.__post_init__(self) # Normalize and validate text_alignment if isinstance(self.text_alignment, str): @@ -1190,23 +1259,6 @@ class DimensionGizmoConfig: 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}"] @@ -1225,6 +1277,20 @@ class DimensionGizmoConfig: return f"DimensionGizmoConfig({', '.join(parts)})" +@dataclass(slots=True) +class IconActionConfig: + """Declarative config for a single icon-action gizmo (one-shot click, + no value, no drag state). + + ``visibility_condition``: optional ``(obj) -> bool`` predicate hiding + this one icon. ``None`` means always visible while the group is polled.""" + + name: str + icon: str + operator: str + visibility_condition: Callable[[Any], bool] | None = None + + class SnapManager: """Manages snap point visualization and mesh snapping with caching.""" @@ -1602,13 +1668,32 @@ def get_billboard_rotation(context: bpy.types.Context) -> Matrix: 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.""" +def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = DEFAULT_BILLBOARD_SCALE) -> Matrix: + """Compose the standard icon matrix_basis: translate to ``world_pos``, billboard to the camera, + then uniformly scale.""" return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4) +# Dead-band on the screen-X delta — prevents flicker when the gizmo sits on the +# element origin. +EXTEND_FLIP_EPSILON = 1e-4 + +# Post-multipliers that mirror a billboarded matrix about its local X / Y axis. +EXTEND_FLIP_MIRROR_X = Matrix.Diagonal(Vector((-1.0, 1.0, 1.0, 1.0))) +EXTEND_FLIP_MIRROR_Y = Matrix.Diagonal(Vector((1.0, -1.0, 1.0, 1.0))) + + +def should_flip_extend_arrow( + gizmo_world: Vector, + reference_world: Vector, + billboard_rot: Matrix, +) -> bool: + """True when ``reference_world`` projects to screen-right of ``gizmo_world`` — + mirror the extend arrow's local X so it points away from the reference in screen space.""" + screen_delta = billboard_rot.transposed() @ (reference_world - gizmo_world) + return screen_delta.x > EXTEND_FLIP_EPSILON + + def setup_icon_gizmo( gizmo_group: bpy.types.GizmoGroup, gizmo_type: str, @@ -1617,9 +1702,8 @@ def setup_icon_gizmo( 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.""" + """Create an icon gizmo with the Bonsai defaults (no draw-scale, fixed + alpha, click-to-operator).""" gizmo = gizmo_group.gizmos.new(gizmo_type) gizmo.use_draw_scale = False gizmo.color = color @@ -1629,6 +1713,11 @@ def setup_icon_gizmo( return gizmo +def get_warning_color_from_prefs(prefs) -> tuple[float, float, float]: + """Hover color for destructive gizmo icons (split, unjoin, delete).""" + return prefs.decorator_color_error[:3] + + # --- 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 @@ -1657,23 +1746,215 @@ def swap_xy_tris( 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``.""" +# Module-level GPU caches for StaticTrisGizmoMixin. Batches are keyed by +# concrete subclass (each has its own ``tris``); the shader is a single +# UNIFORM_COLOR instance shared across all icon-class gizmos. Both must be +# cleared on addon unregister + ``load_post`` because GPUBatch / GPUShader +# references hold GPU resources that go stale across blend-file reloads. +_static_tris_batches: dict[type, "gpu.types.GPUBatch"] = {} +_static_tris_shader = None + + +def _get_static_tris_shader(): + global _static_tris_shader + if _static_tris_shader is None: + _static_tris_shader = gpu.shader.from_builtin("UNIFORM_COLOR") + return _static_tris_shader + + +def _get_static_tris_batch(cls): + batch = _static_tris_batches.get(cls) + if batch is None: + batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": cls.tris}) + _static_tris_batches[cls] = batch + return batch + + +def clear_static_tris_cache() -> None: + """Drops the cached per-class TRIS batches and shader. Wired into addon + teardown + ``load_post`` so GPU resources don't outlive their context.""" + global _static_tris_shader + _static_tris_batches.clear() + _static_tris_shader = None + + +# Single source of truth for icon-class outline defaults. Referenced from +# both ``StaticTrisGizmoMixin`` (class-attribute defaults a concrete gizmo +# can override per-class) and ``draw_tris_with_outline`` (helper called +# from dynamic-tris gizmos that don't inherit the mixin). ``_OUTLINE_DIRECTIONS_8`` +# lives near ``DimensionRenderer`` because both consumers reference it. +_OUTLINE_DEFAULT_WIDTH = 0.03 +_OUTLINE_DEFAULT_ALPHA = 0.4 + + +def _draw_outline_and_body( + shader: "gpu.types.GPUShader", + batch: "gpu.types.GPUBatch", + base_matrix: Matrix, + color: tuple[float, float, float, float], + outline_width: float, + outline_alpha: float, +) -> None: + """Renders 8 outline passes (semi-transparent black, offset by + ``outline_width`` in the cardinal + diagonal unit directions) followed + by the body pass at ``color``, wrapped in ALPHA blend state. + + Caller must bind the shader and configure any sampler / texture + uniforms before calling. The ``color`` uniform is set internally for + each pass — caller's ``color`` uniform is overwritten.""" + with GPUStateScope(blend="ALPHA"): + if outline_alpha > 0.0 and outline_width > 0.0: + shader.uniform_float("color", (0.0, 0.0, 0.0, outline_alpha)) + for dx, dy in _OUTLINE_DIRECTIONS_8: + offset_matrix = base_matrix @ Matrix.Translation((dx * outline_width, dy * outline_width, 0.0)) + with gpu.matrix.push_pop(): + gpu.matrix.multiply_matrix(offset_matrix) + batch.draw(shader) + shader.uniform_float("color", color) + with gpu.matrix.push_pop(): + gpu.matrix.multiply_matrix(base_matrix) + batch.draw(shader) + + +def draw_tris_with_outline( + batch: "gpu.types.GPUBatch", + base_matrix: Matrix, + color: tuple[float, float, float, float], + outline_width: float = _OUTLINE_DEFAULT_WIDTH, + outline_alpha: float = _OUTLINE_DEFAULT_ALPHA, +) -> None: + """Renders ``batch`` as an opaque tris body with an 8-way dark halo behind. + + Shared between StaticTrisGizmoMixin and custom-draw gizmos with dynamic + tris. The caller supplies the per-frame matrix and the icon color; this + routine handles shader binding, the eight outline passes, the body + pass, and the surrounding GPU blend state.""" + shader = _get_static_tris_shader() + shader.bind() + _draw_outline_and_body(shader, batch, base_matrix, color, outline_width, outline_alpha) + + +class StaticTrisGizmoMixin: + """Mixin for gizmos drawing a static class-level ``tris`` tuple. + + Renders the icon nine times: eight outline passes (the silhouette in + semi-transparent black, offset by ``outline_width`` in eight unit-length + directions), then the icon itself at its normal color. The union of the + eight offset silhouettes approximates a circular dilation of the icon, + producing a uniform dark halo on every side — keeps glyphs legible on + any background (white walls, white mesh, dark theme, dark mesh). + Disable per-class with ``outline_alpha = 0.0`` or ``outline_width = 0``.""" + + # Outline ring width in local tris coordinates. The existing tris span + # roughly ±0.3 to ±0.45 in local XY; 0.03 produces a ~6–10% halo on + # every side, readable on any background without crowding the glyph. + outline_width: float = _OUTLINE_DEFAULT_WIDTH + # Per-pass alpha. Eight overlapping passes accumulate where they meet, + # so 0.4 per pass produces a near-opaque inner ring (~0.98 cumulative) + # and a clearly visible outer fade (single-pass 0.4 at the dilation edge). + outline_alpha: float = _OUTLINE_DEFAULT_ALPHA + # When True, hit shape is the glyph's 2D bounding box (plus ``outline_width`` + # padding) — clickable surface matches the visible tile, no dead zones. + # Subclasses used in tight stacks (where adjacent icons sit closer than the + # bbox extent) should set this False so each icon's hit area stays inside + # its glyph and adjacent icons don't steal each other's clicks. + hit_uses_bbox: bool = True def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) + if self.hit_uses_bbox: + xs = [v[0] for v in self.tris] + ys = [v[1] for v in self.tris] + pad = self.outline_width + hit_tris = rect_tris(min(xs) - pad, min(ys) - pad, max(xs) + pad, max(ys) + pad) + else: + hit_tris = self.tris + self.custom_shape = self.new_custom_shape("TRIS", hit_tris) def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) + # Icon body is forced fully opaque: any ``self.alpha`` < 1.0 would + # let the dark outline behind bleed through and grey out the glyph. + # Hover-vs-default is conveyed by RGB only. + if self.is_highlight: + color = (*self.color_highlight, 1.0) + else: + color = (*self.color, 1.0) + draw_tris_with_outline( + _get_static_tris_batch(type(self)), + self.matrix_basis @ self.matrix_offset, + color, + self.outline_width, + self.outline_alpha, + ) def draw_select(self, context: bpy.types.Context, select_id: int) -> None: self.draw_custom_shape(self.custom_shape, select_id=select_id) +# Unit quad in the Z=0 plane — same local space as icon-class ``tris`` tuples, +# so ``matrix_basis`` / ``scale_basis`` position it identically. +_TEXTURED_QUAD_POSITIONS = ( + (-0.5, -0.5, 0.0), + (0.5, -0.5, 0.0), + (0.5, 0.5, 0.0), + (-0.5, 0.5, 0.0), +) +_TEXTURED_QUAD_TEX_COORDS = ( + (0.0, 0.0), + (1.0, 0.0), + (1.0, 1.0), + (0.0, 1.0), +) + + +class TexturedQuadGizmoMixin(StaticTrisGizmoMixin): + """Renders a billboarded textured quad from ``bim/data/icons/.png``. + + Inherits ``StaticTrisGizmoMixin`` on purpose: ``draw_select`` and the + tris fallback stay available. Any texture failure (missing PNG, GPU + init error, mid-reload race) falls through to ``super().draw`` so the + gizmo never disappears. ``outline_scale`` / ``outline_alpha`` are + inherited from the parent and apply identically — IMAGE_COLOR multiplies + the sampled texel by the uniform color, so a black-tinted scaled-up pass + produces a dark halo around the PNG silhouette.""" + + icon_name: str = "" + + def setup(self) -> None: + super().setup() + from bonsai.bim.module.drawing import gizmo_textures + + self._quad_batch = batch_for_shader( + gizmo_textures.get_shader(), + "TRI_FAN", + {"pos": _TEXTURED_QUAD_POSITIONS, "texCoord": _TEXTURED_QUAD_TEX_COORDS}, + ) + + def draw(self, context: bpy.types.Context) -> None: + from bonsai.bim.module.drawing import gizmo_textures + + texture = gizmo_textures.get_icon_texture(self.icon_name) + if texture is None: + super().draw(context) + return + shader = gizmo_textures.get_shader() + # Icon body forced fully opaque so the dark outline behind doesn't + # bleed through the texture and grey out the glyph. + if self.is_highlight: + color = (*self.color_highlight, 1.0) + else: + color = (*self.color, 1.0) + shader.bind() + shader.uniform_sampler("image", texture) + _draw_outline_and_body( + shader, + self._quad_batch, + self.matrix_basis @ self.matrix_offset, + color, + self.outline_width, + self.outline_alpha, + ) + + def get_camera_direction(context: bpy.types.Context, position: Vector) -> Vector | None: """Get normalized direction from position towards camera.""" rv3d = context.region_data @@ -2045,18 +2326,14 @@ class OffsetHandle: return {"CANCELLED"} delta = coordz - self.init_coordz if "PRECISE" in tweak: - delta /= 10.0 + delta *= PRECISION_MODE_MULTIPLIER 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 @@ -2124,7 +2401,6 @@ class ExtrusionGuidesGizmo(CustomGizmo, types.Gizmo): __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) @@ -2195,7 +2471,6 @@ class ExtrusionWidget(types.GizmoGroup): gz.scale_value = scale_value def refresh(self, context: bpy.types.Context) -> None: - """updating gizmos""" target = context.active_object if not target: return @@ -2204,7 +2479,6 @@ class ExtrusionWidget(types.GizmoGroup): 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: @@ -2513,6 +2787,16 @@ class GizmoMovable(bpy.types.Gizmo): # Threshold in pixels for considering mouse movement as a drag DRAG_THRESHOLD = 5 + def _get_triangles(self) -> tuple[tuple[float, float, float], ...]: + """Subclasses must return TRIS-mode geometry for the custom shape.""" + raise NotImplementedError(f"{type(self).__name__} must define _get_triangles()") + + def setup(self) -> None: + self.custom_shape = self.new_custom_shape("TRIS", self._get_triangles()) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self.draw_custom_shape(self.custom_shape, select_id=select_id) + 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() @@ -2800,172 +3084,265 @@ class GizmoMovable(bpy.types.Gizmo): blf.disable(font_id, blf.SHADOW) -class GizmoLock(bpy.types.Gizmo): - """Lock icon gizmo that switches between closed and open states.""" +LOCK_TRIS_OPEN = ( + (-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), +) - 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) +LOCK_TRIS_CLOSED = ( + (-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), +) -class GizmoArc(bpy.types.Gizmo): - """Arc gizmo for door swing visualization.""" +class GizmoLockOpen(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Static open-padlock glyph.""" + + bl_idname = "VIEW3D_GT_lock_open" + __slots__ = ("custom_shape",) + tris = LOCK_TRIS_OPEN + + +class GizmoLockClosed(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Static closed-padlock glyph.""" + + bl_idname = "VIEW3D_GT_lock_closed" + __slots__ = ("custom_shape",) + tris = LOCK_TRIS_CLOSED + + +ARC_TRIS_DEFAULT = create_circle_arc( + radius=1.0, direction="LEFT", angle_min=DOOR_SWING_ANGLE_MIN, angle_max=DOOR_SWING_ANGLE_MAX +) + + +class GizmoArc(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Static quarter-arc glyph for swing visualisation. + + Consumers needing the mirrored (RIGHT) visual apply a flip-X matrix to + ``matrix_basis``.""" bl_idname = "VIEW3D_GT_arc" + __slots__ = ("custom_shape",) + tris = ARC_TRIS_DEFAULT - __slots__ = ( - "custom_shape_left", - "custom_shape_right", - "prop_path", + +def _fillet_icon_tris() -> tuple[tuple[float, float, float], ...]: + """Filled L-glyph with a smoothly rounded corner — two perpendicular + wall bars joined by a constant-thickness arc band.""" + arc_center_x = 0.0 + arc_center_y = 0.0 + r_outer = 0.28 + r_inner = 0.18 # thickness = 0.10 + arc_segments = 8 + + # Banana sweeps from 270° (downward radial) to 360° = 0° (rightward + # radial). The bars extend the wall material outward from the banana's + # two end caps along the tangent direction. + outer_at_start = (arc_center_x, arc_center_y - r_outer) # 270°, outer + inner_at_start = (arc_center_x, arc_center_y - r_inner) # 270°, inner + outer_at_end = (arc_center_x + r_outer, arc_center_y) # 0°, outer + inner_at_end = (arc_center_x + r_inner, arc_center_y) # 0°, inner + + bar_a_left = -0.45 # horizontal bar extends from banana cap LEFTWARD + bar_b_top = 0.45 # vertical bar extends from banana cap UPWARD + + tris: list[tuple[float, float, float]] = [] + # Horizontal bar: tangent at 270° (downward radial), tangent direction is +X. + # The bar lies along +X with cross-section in radial direction (y). + tris.extend(rect_tris(bar_a_left, outer_at_start[1], outer_at_start[0], inner_at_start[1])) + # Vertical bar: tangent at 0° (rightward radial), tangent direction is +Y. + # The bar lies along +Y with cross-section in radial direction (x). + tris.extend(rect_tris(inner_at_end[0], outer_at_end[1], outer_at_end[0], bar_b_top)) + + # Quarter-banana sector: each angular slice → trapezoid → two CCW triangles. + angle_start = 3.0 * math.pi / 2.0 # 270° + angle_end = 2.0 * math.pi # 360° / 0° + for i in range(arc_segments): + a1 = angle_start + (angle_end - angle_start) * (i / arc_segments) + a2 = angle_start + (angle_end - angle_start) * ((i + 1) / arc_segments) + outer1 = (arc_center_x + r_outer * math.cos(a1), arc_center_y + r_outer * math.sin(a1)) + outer2 = (arc_center_x + r_outer * math.cos(a2), arc_center_y + r_outer * math.sin(a2)) + inner1 = (arc_center_x + r_inner * math.cos(a1), arc_center_y + r_inner * math.sin(a1)) + inner2 = (arc_center_x + r_inner * math.cos(a2), arc_center_y + r_inner * math.sin(a2)) + tris.append((outer1[0], outer1[1], 0.0)) + tris.append((outer2[0], outer2[1], 0.0)) + tris.append((inner2[0], inner2[1], 0.0)) + tris.append((outer1[0], outer1[1], 0.0)) + tris.append((inner2[0], inner2[1], 0.0)) + tris.append((inner1[0], inner1[1], 0.0)) + return tuple(tris) + + +FILLET_TRIS_DEFAULT = _fillet_icon_tris() + + +class GizmoFillet(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Filled fillet glyph for wall-corner rounding.""" + + bl_idname = "VIEW3D_GT_fillet" + __slots__ = ("custom_shape",) + tris = FILLET_TRIS_DEFAULT + # Stacked at ICON_STACK_OFFSET_Y above join in GizmoWallJoinIntersection; + # full-bbox hit overlaps the sibling icons' bboxes and steals their clicks. + hit_uses_bbox = False + + +def _wall_corner_icon_tris() -> tuple[tuple[float, float, float], ...]: + """Filled L-glyph with a sharp 90° inner corner.""" + # Match the fillet icon's bar thickness so the row reads at one visual weight. + outer_y = -0.28 + inner_y = -0.18 + outer_x = 0.28 + inner_x = 0.18 + bar_a_left = -0.45 + bar_b_top = 0.45 + + tris: list[tuple[float, float, float]] = [] + # Bars overlap at the corner square so the L renders as one continuous material. + tris.extend(rect_tris(bar_a_left, outer_y, outer_x, inner_y)) + tris.extend(rect_tris(inner_x, outer_y, outer_x, bar_b_top)) + return tuple(tris) + + +WALL_CORNER_TRIS_DEFAULT = _wall_corner_icon_tris() + + +class GizmoWallCornerIcon(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Filled L-shape glyph (sharp 90° corner) for joining two walls.""" + + bl_idname = "VIEW3D_GT_wall_corner" + __slots__ = ("custom_shape",) + tris = WALL_CORNER_TRIS_DEFAULT + hit_uses_bbox = False # tight stack in GizmoWallJoinIntersection — see GizmoFillet + + +def _wall_tee_icon_tris() -> tuple[tuple[float, float, float], ...]: + """Filled side-T glyph (⊣ orientation) for extending one wall into + another's side. The through wall (vertical bar, right edge) carries a + branching wall (horizontal bar) butting into its midline — visually + distinguishes 'extend wall to wall' from the L-corner 'join' glyph by + *where* the bars meet (middle vs corner).""" + # Match the wall-corner bbox + bar thickness so the icon row reads at + # one visual weight. + bar_lo_y = -0.28 + bar_top = 0.45 + through_inner_x = 0.18 + through_outer_x = 0.28 + branch_left = -0.45 + # Branching bar centered on the through-bar's midline so the vertical + # extends equally above and below — reads as a balanced ⊣. + branch_mid_y = (bar_lo_y + bar_top) / 2 + branch_half_thickness = 0.05 + + tris: list[tuple[float, float, float]] = [] + tris.extend(rect_tris(through_inner_x, bar_lo_y, through_outer_x, bar_top)) + # Branching bar's right edge stops at the through-bar's inner edge so the + # bars touch without overlapping. + tris.extend( + rect_tris( + branch_left, + branch_mid_y - branch_half_thickness, + through_inner_x, + branch_mid_y + branch_half_thickness, + ) ) - - 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) + return tuple(tris) -class GizmoPen(bpy.types.Gizmo): +WALL_TEE_TRIS_DEFAULT = _wall_tee_icon_tris() + + +class GizmoWallTeeIcon(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Filled T-junction glyph for extending one wall into another's side.""" + + bl_idname = "VIEW3D_GT_wall_tee" + __slots__ = ("custom_shape",) + tris = WALL_TEE_TRIS_DEFAULT + hit_uses_bbox = False # tight stack in GizmoWallJoinIntersection — see GizmoFillet + + +class GizmoPen(StaticTrisGizmoMixin, bpy.types.Gizmo): """Pen/edit icon gizmo for entering edit mode.""" bl_idname = "VIEW3D_GT_pen" @@ -2990,17 +3367,8 @@ class GizmoPen(bpy.types.Gizmo): (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): +class GizmoValidate(StaticTrisGizmoMixin, bpy.types.Gizmo): """Validate/checkmark icon gizmo for confirming edits.""" bl_idname = "VIEW3D_GT_validate" @@ -3022,17 +3390,8 @@ class GizmoValidate(bpy.types.Gizmo): (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): +class GizmoCancel(StaticTrisGizmoMixin, bpy.types.Gizmo): """Cancel/X icon gizmo for canceling edits.""" bl_idname = "VIEW3D_GT_cancel" @@ -3072,17 +3431,8 @@ class GizmoCancel(bpy.types.Gizmo): (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): +class GizmoPlus(StaticTrisGizmoMixin, bpy.types.Gizmo): """Plus icon gizmo for incrementing values.""" bl_idname = "VIEW3D_GT_plus" @@ -3104,17 +3454,8 @@ class GizmoPlus(bpy.types.Gizmo): (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): +class GizmoMinus(StaticTrisGizmoMixin, bpy.types.Gizmo): """Minus icon gizmo for decrementing values.""" bl_idname = "VIEW3D_GT_minus" @@ -3130,17 +3471,281 @@ class GizmoMinus(bpy.types.Gizmo): (0.375, -0.075, 0.0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) + +class GizmoTrash(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Wastebasket icon for destructive delete actions — body + lid + handle.""" + + bl_idname = "VIEW3D_GT_trash" + + __slots__ = ("custom_shape",) + + # Trash-can profile within the conventional ±0.375 icon bounding box. + # Sized ~15% larger than the baseline 3-rect icon design so the + # destructive button reads as the visual end-stop of the row. Solid + # fills match the Bonsai gizmo-icon convention (Plus / Minus / Cancel). + tris = ( + # Body — slightly narrower than the lid for the classic bin shape. + *rect_tris(-0.23, -0.345, 0.23, 0.207), + # Lid — extends wider on both sides so it sits "over" the body. + *rect_tris(-0.31, 0.207, 0.31, 0.30), + # Handle — small bar centered on top of the lid. + *rect_tris(-0.09, 0.30, 0.09, 0.39), + ) + + +class GizmoArrayParent(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Hierarchy tree glyph — one top node connected to three bottom nodes + by short lines. Fires the operator that selects the parent object of an + array given a child is currently active.""" + + bl_idname = "VIEW3D_GT_array_parent" + + __slots__ = ("custom_shape",) + + # Hierarchy tree: one top node + three bottom nodes wired by trunk + + # crossbar + drop legs. Conventional ±0.375 icon bounding box. + tris = ( + # Top (parent) node. + *rect_tris(-0.075, 0.195, 0.075, 0.345), + # Three child nodes along the bottom row. + *rect_tris(-0.335, -0.335, -0.205, -0.205), + *rect_tris(-0.065, -0.335, 0.065, -0.205), + *rect_tris(0.205, -0.335, 0.335, -0.205), + # Vertical trunk: top node down through the crossbar to the centre child. + *rect_tris(-0.02, -0.205, 0.02, 0.195), + # Horizontal crossbar joining the trunk's midpoint to left/right legs. + *rect_tris(-0.27, -0.07, 0.27, -0.03), + # Drop legs from crossbar to the left and right children. + *rect_tris(-0.29, -0.205, -0.25, -0.07), + *rect_tris(0.25, -0.205, 0.29, -0.07), + ) + + +def _quad_tris(x0: float, y0: float, x1: float, y1: float) -> tuple: + """Two CCW triangles covering rectangle ``(x0,y0)-(x1,y1)`` in Z=0.""" + return ( + (x0, y0, 0.0), (x1, y0, 0.0), (x1, y1, 0.0), + (x0, y0, 0.0), (x1, y1, 0.0), (x0, y1, 0.0), + ) # fmt: skip + + +# 7-segment digit definitions for world-space integer-label gizmos. Each digit's +# strokes fit inside a unit-cell (width 0.22, height 0.40) centred on (0, 0); the +# label builder translates the cell into the final position. Composed of seven +# rectangle "segments" — top, mid, bot horizontals + upper-left/right and +# lower-left/right verticals — so the count gizmo can render any integer 0-9999 +# without an external font. +_DIGIT_STROKES = { + "top": (-0.10, 0.18, 0.10, 0.20), + "mid": (-0.10, -0.02, 0.10, 0.02), + "bot": (-0.10, -0.20, 0.10, -0.18), + "ul": (-0.10, 0.00, -0.07, 0.20), + "ur": (0.07, 0.00, 0.10, 0.20), + "ll": (-0.10, -0.20, -0.07, 0.00), + "lr": (0.07, -0.20, 0.10, 0.00), +} # fmt: skip +_DIGIT_SEGMENTS = { + "0": ("top", "ul", "ur", "ll", "lr", "bot"), + "1": ("ur", "lr"), + "2": ("top", "ur", "mid", "ll", "bot"), + "3": ("top", "ur", "mid", "lr", "bot"), + "4": ("ul", "ur", "mid", "lr"), + "5": ("top", "ul", "mid", "lr", "bot"), + "6": ("top", "ul", "mid", "ll", "lr", "bot"), + "7": ("top", "ur", "lr"), + "8": ("top", "ul", "ur", "mid", "ll", "lr", "bot"), + "9": ("top", "ul", "ur", "mid", "lr", "bot"), +} +# Width of one digit cell including its trailing kerning gap. ``x`` prefix is +# rendered as two crossed diagonals across one cell of the same width. +_DIGIT_CELL_W = 0.26 + + +def _digit_tris(digit: str, cx: float, cy: float) -> tuple: + """Triangles for one ``"0"``..``"9"`` digit centred on ``(cx, cy)``.""" + tris: list[tuple[float, float, float]] = [] + for seg in _DIGIT_SEGMENTS[digit]: + x0, y0, x1, y1 = _DIGIT_STROKES[seg] + tris.extend(_quad_tris(x0 + cx, y0 + cy, x1 + cx, y1 + cy)) + return tuple(tris) + + +def _x_prefix_tris(cx: float, cy: float) -> tuple: + """Triangles for an ``x`` glyph centred on ``(cx, cy)`` — two crossed + diagonals roughly matching a digit's height for the count label.""" + # Each leg is a thin rectangle rotated 45° from the cell centre. Vertex + # coords are precomputed: half-length 0.13 along the rotated axis, half + # width 0.025 perpendicular. Using two quads keeps it TRIS-only. + leg = 0.13 + w = 0.025 + # Leg 1 (top-left → bottom-right). + p1 = (cx - leg - w, cy + leg - w, 0.0) + p2 = (cx - leg + w, cy + leg + w, 0.0) + p3 = (cx + leg + w, cy - leg + w, 0.0) + p4 = (cx + leg - w, cy - leg - w, 0.0) + # Leg 2 (top-right → bottom-left). + q1 = (cx + leg - w, cy + leg + w, 0.0) + q2 = (cx + leg + w, cy + leg - w, 0.0) + q3 = (cx - leg + w, cy - leg - w, 0.0) + q4 = (cx - leg - w, cy - leg + w, 0.0) + return ( + p1, p2, p3, p1, p3, p4, + q1, q2, q3, q1, q3, q4, + ) # fmt: skip + + +def _count_label_tris(count: int, cx: float, cy: float) -> tuple: + """Triangles for an ``xN`` label centred on ``(cx, cy)``. Composes the + ``x`` prefix and each base-10 digit horizontally.""" + digits = str(max(0, int(count))) + total_w = _DIGIT_CELL_W * (1 + len(digits)) + start_x = cx - total_w / 2 + _DIGIT_CELL_W / 2 + tris: list[tuple[float, float, float]] = [] + tris.extend(_x_prefix_tris(start_x, cy)) + for i, d in enumerate(digits): + tris.extend(_digit_tris(d, start_x + (i + 1) * _DIGIT_CELL_W, cy)) + return tuple(tris) + + +class GizmoArrayAll(StaticTrisGizmoMixin, bpy.types.Gizmo): + """2×2 grid of small filled squares — multi-select for an array + (parent + all children). + + On hover from an array child, paints a wireframe bbox around every + sibling in the same array layer.""" + + bl_idname = "VIEW3D_GT_array_all" + + __slots__ = ("custom_shape",) + + # Four small filled squares in a 2x2 grid, each 0.2 wide with a 0.15 gap + # between rows / columns so the grid reads as discrete cells rather than a + # solid block. All within the ±0.375 icon bounding-box convention. + tris = ( + *_quad_tris(-0.275, 0.075, -0.075, 0.275), # top-left + *_quad_tris(0.075, 0.075, 0.275, 0.275), # top-right + *_quad_tris(-0.275, -0.275, -0.075, -0.075), # bottom-left + *_quad_tris(0.075, -0.275, 0.275, -0.075), # bottom-right + ) def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) + super().draw(context) + if self.is_highlight: + self._draw_containing_array_bbox(context) + + def _draw_containing_array_bbox(self, context: bpy.types.Context) -> None: + """Outline every sibling of the active child in the array layer that + produced it. No-op when no resolvable parent / layer.""" + obj = context.active_object + if obj is None: + return + child_element = tool.Ifc.get_entity(obj) + if child_element is None: + return + layer_index = tool.Array.get_child_layer_index(child_element) + if layer_index is None: + return + pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array") + if not pset: + return + parent_guid = pset.get("Parent") + if not parent_guid: + return + try: + parent_element = tool.Ifc.get().by_guid(parent_guid) + except RuntimeError: + return + from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox + + draw_array_layer_children_bbox(context, parent_element, layer_index) + + +class GizmoArrayLayerIndicator(bpy.types.Gizmo): + """ARRAY layer entry icon with a world-space ``xN`` count rendered above. + + The 2×2-grid glyph sits in the bottom half of the local frame; the + ``xN`` count is composed of 7-segment digit triangles in the top half. + Both are part of the gizmo's custom shape so the entire glyph is a + single click target. + + On hover, ``draw()`` paints a wireframe bbox around every child of this + layer in the same 3D pass — drawing inline keeps the bbox in lockstep + with the highlight.""" + + bl_idname = "BIM_GT_array_layer_indicator" + + __slots__ = ("custom_shape", "_count", "_built_count", "_layer_index", "_outlined_batch") + + # Icon glyph (2×2 grid) translated down so the upper half stays free for + # the count label. Centred so the gizmo's world anchor falls between the + # icon and the label. + _ICON_TRIS = ( + *_quad_tris(-0.275, -0.475, -0.075, -0.275), + *_quad_tris(0.075, -0.475, 0.275, -0.275), + *_quad_tris(-0.275, -0.225, -0.075, -0.025), + *_quad_tris(0.075, -0.225, 0.275, -0.025), + ) + # Vertical centre of the count label in the gizmo's local frame. + _LABEL_Y = 0.22 + + def setup(self) -> None: + self._count = 0 + self._built_count = -1 + # ``-1`` until the gizmo group calls ``set_layer_index``. The bbox + # highlight no-ops while the index is unassigned. + self._layer_index = -1 + tris = self._build_tris() + self.custom_shape = self.new_custom_shape("TRIS", tris) + self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris}) + self._built_count = 0 + + def set_count(self, count: int) -> None: + self._count = int(count) + + def set_layer_index(self, layer_index: int) -> None: + self._layer_index = int(layer_index) + + def _build_tris(self) -> tuple: + return self._ICON_TRIS + _count_label_tris(self._count, 0.0, self._LABEL_Y) + + def _ensure_shape(self) -> None: + if self._built_count != self._count: + tris = self._build_tris() + self.custom_shape = self.new_custom_shape("TRIS", tris) + self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris}) + self._built_count = self._count + + def draw(self, context: bpy.types.Context) -> None: + self._ensure_shape() + if self.is_highlight: + color = (*self.color_highlight, 1.0) + else: + color = (*self.color, 1.0) + draw_tris_with_outline(self._outlined_batch, self.matrix_basis @ self.matrix_offset, color) + if self.is_highlight: + self._draw_layer_children_bbox(context) def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self._ensure_shape() self.draw_custom_shape(self.custom_shape, select_id=select_id) + def _draw_layer_children_bbox(self, context: bpy.types.Context) -> None: + """Outline this layer's children inline so the bbox stays in lockstep + with the gizmo highlight.""" + if self._layer_index < 0: + return + obj = context.active_object + if obj is None: + return + parent_element = tool.Ifc.get_entity(obj) + if parent_element is None: + return + from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox -class GizmoMerge(TrisGizmoMixin, bpy.types.Gizmo): + draw_array_layer_children_bbox(context, parent_element, self._layer_index) + + +class GizmoMerge(StaticTrisGizmoMixin, bpy.types.Gizmo): """Two arrows pointing inward toward each other — conveys joining/merging elements.""" bl_idname = "VIEW3D_GT_merge" @@ -3166,7 +3771,7 @@ class GizmoMerge(TrisGizmoMixin, bpy.types.Gizmo): ) -class GizmoSplit(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoSplit(StaticTrisGizmoMixin, bpy.types.Gizmo): """Two arrows pointing outward away from each other — conveys splitting/cutting one element into two. Visual inverse of `GizmoMerge`.""" @@ -3193,7 +3798,33 @@ class GizmoSplit(TrisGizmoMixin, bpy.types.Gizmo): ) -class GizmoExtend(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoUnjoin(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Two C-shaped hooks facing each other across a clear gap — conveys + severing a relationship between two elements (e.g. an + ``IfcRelConnectsPathElements`` between two walls). The "two linked + things pulled apart" silhouette reads as relationship-cut rather than + geometry-cut.""" + + bl_idname = "VIEW3D_GT_unjoin" + + __slots__ = ("custom_shape",) + + # Each hook is three solid bars composing a C: top, bottom, and back + # wall. The two C's face inward across a clear gap so the silhouette + # reads as "two interlocking links pulled apart". + tris = ( + # Left hook — C opening to the right. + *rect_tris(-0.30, 0.11, -0.08, 0.17), + *rect_tris(-0.30, -0.17, -0.08, -0.11), + *rect_tris(-0.30, -0.17, -0.24, 0.17), + # Right hook — mirror, C opening to the left. + *rect_tris(0.08, 0.11, 0.30, 0.17), + *rect_tris(0.08, -0.17, 0.30, -0.11), + *rect_tris(0.24, -0.17, 0.30, 0.17), + ) + + +class GizmoExtend(StaticTrisGizmoMixin, 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).""" @@ -3215,7 +3846,7 @@ class GizmoExtend(TrisGizmoMixin, bpy.types.Gizmo): ) -class GizmoExtendVertical(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoExtendVertical(StaticTrisGizmoMixin, bpy.types.Gizmo): """Vertical sibling of `GizmoExtend` — arrow pointing UP into a horizontal bar. Conveys extending an element's height to a target Z.""" @@ -3235,7 +3866,7 @@ def _offset_baseline_tris(mark_x: float) -> tuple[tuple[float, float, float], .. 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): +class GizmoOffsetExterior(StaticTrisGizmoMixin, bpy.types.Gizmo): """Wall offset baseline indicator — reference axis at the exterior face (left mark).""" bl_idname = "VIEW3D_GT_offset_exterior" @@ -3243,7 +3874,7 @@ class GizmoOffsetExterior(TrisGizmoMixin, bpy.types.Gizmo): tris = _offset_baseline_tris(-0.24) -class GizmoOffsetCenter(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoOffsetCenter(StaticTrisGizmoMixin, bpy.types.Gizmo): """Wall offset baseline indicator — reference axis at the centreline (middle mark).""" bl_idname = "VIEW3D_GT_offset_center" @@ -3251,7 +3882,7 @@ class GizmoOffsetCenter(TrisGizmoMixin, bpy.types.Gizmo): tris = _offset_baseline_tris(0.0) -class GizmoOffsetInterior(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoOffsetInterior(StaticTrisGizmoMixin, bpy.types.Gizmo): """Wall offset baseline indicator — reference axis at the interior face (right mark).""" bl_idname = "VIEW3D_GT_offset_interior" @@ -3259,7 +3890,7 @@ class GizmoOffsetInterior(TrisGizmoMixin, bpy.types.Gizmo): tris = _offset_baseline_tris(0.24) -class GizmoAddOpening(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoAddOpening(StaticTrisGizmoMixin, bpy.types.Gizmo): """A rectangular frame (square outline with a hole in the middle) — conveys adding an opening (window/door/void) to a wall.""" @@ -3372,7 +4003,7 @@ def _generate_circular_arrow_tris() -> tuple[tuple[float, float, float], ...]: return tuple(triangles) -class GizmoCycle(bpy.types.Gizmo): +class GizmoCycle(StaticTrisGizmoMixin, bpy.types.Gizmo): """Circular arrow icon gizmo for cycling through enum values.""" bl_idname = "VIEW3D_GT_cycle" @@ -3381,14 +4012,42 @@ class GizmoCycle(bpy.types.Gizmo): 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 _generate_menu_tris() -> tuple[tuple[float, float, float], ...]: + """Three stacked horizontal bars — universal "menu / pick from list" glyph.""" + # Sized ~30% larger than the validate / cancel icon family so the picker + # affordance reads more strongly — picking a type is a higher-stakes click + # than the surrounding edit-mode toggles. + bar_half_thickness = 0.046 + bar_half_width = 0.26 + vertical_spacing = 0.182 + return ( + *rect_tris( + -bar_half_width, + +vertical_spacing - bar_half_thickness, + +bar_half_width, + +vertical_spacing + bar_half_thickness, + ), + *rect_tris(-bar_half_width, -bar_half_thickness, +bar_half_width, +bar_half_thickness), + *rect_tris( + -bar_half_width, + -vertical_spacing - bar_half_thickness, + +bar_half_width, + -vertical_spacing + bar_half_thickness, + ), + ) - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) + +class GizmoMenu(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Hamburger-stack menu icon — 'open a picker to choose from many options'. + + For enums with 5+ values; use ``GizmoCycle`` for 2-4.""" + + bl_idname = "VIEW3D_GT_menu" + + __slots__ = ("custom_shape",) + + tris = _generate_menu_tris() class GizmoArrow(GizmoMovable): @@ -3397,7 +4056,7 @@ class GizmoArrow(GizmoMovable): 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], ...]: + def _get_triangles(self) -> tuple[tuple[float, float, float], ...]: triangles = [] triangles.extend( @@ -3466,16 +4125,10 @@ class GizmoArrow(GizmoMovable): 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.""" @@ -3488,7 +4141,7 @@ class GizmoArrow2D(GizmoMovable): ARROW_2D_WIDTH = 0.25 ARROW_2D_HEAD_WIDTH = 0.75 - def _get_arrow_2d_triangles(self) -> tuple[tuple[float, float, float], ...]: + def _get_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 @@ -3509,16 +4162,10 @@ class GizmoArrow2D(GizmoMovable): (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 @@ -3558,7 +4205,7 @@ class GizmoCone(GizmoMovable): 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], ...]: + def _get_triangles(self) -> tuple[tuple[float, float, float], ...]: triangles = [] cone_tip_x = CONE_LENGTH @@ -3589,15 +4236,9 @@ class GizmoCone(GizmoMovable): 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. @@ -3659,6 +4300,7 @@ class GizmoDimension(GizmoMovable): "_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 + "schematic_attr_name", # Set by BaseSchematicGizmoGroup: attr_name of the bound config, read by hover-highlight ) ARROW_SIZE = 10 @@ -4119,54 +4761,6 @@ class GizmoDimension(GizmoMovable): 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. @@ -4299,6 +4893,7 @@ class BaseParametricGizmoGroup: COLOR_RED = (1.0, 0.2, 0.2) COLOR_GREEN = (0.1, 0.8, 0.1) COLOR_BLUE = (0.3, 0.3, 1.0) + COLOR_NEUTRAL = (1.0, 1.0, 1.0) # === Dimension Gizmo Layout (meters) === ARROW_SCALE = 0.25 # Scale factor for arrow gizmos @@ -4317,14 +4912,51 @@ class BaseParametricGizmoGroup: 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 + # Rightmost local-X used by feature-specific icons (across both idle and + # edit states). Subclasses override when they add icons past the cycle + # slot at 0.87 — currently wall (rotate at 1.24) and stair (minus at + # 1.98). Drives both the ARRAY button position (this class) AND the + # array-layer-icons start position (``GizmoArrayEdition`` runtime lookup), + # so non-colliding features get a tight layout while wall / stair shift + # the array-related slots outward to avoid stomping on the rotate / + # tread-lock / +/- icons. + FEATURE_ICON_MAX_X: float = 0.87 + # Gap between the last feature icon and the ARRAY button (or the first + # array layer icon in idle state). + ICON_ARRAY_GAP: float = 0.37 ICON_Z_OFFSET = 0.5 # Height above element for icons ICON_Y_OFFSET = GIZMO_OFFSET * 2 # Y offset to keep icons clear of geometry + # Offset (meters in world units) used along the screen-up direction when + # world-Z stacking would project to zero on screen (plan / top-down views). + SCREEN_STACK_OFFSET = 0.5 dimension_gizmo_props: list[DimensionGizmoConfig] = [] enable_editing_operator: str = "" finish_editing_operator: str = "" cancel_editing_operator: str = "" + # Mutually exclusive; cycle for 2-4 values, pick for 5+. cycle_type_operator: str = "" + pick_type_operator: str = "" + + REGISTRY: list[type] = [] + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + BaseParametricGizmoGroup.REGISTRY.append(cls) + + @classmethod + def pick_visible_anchor(cls, context: bpy.types.Context, world_base: Vector, world_top: Vector) -> Vector: + """Choose between two anchor candidates so vertical separation stays + visible regardless of view orientation. + + In 3D views the world-Z gap between base and top reads cleanly on + screen, so return ``world_top``. In plan / top-down views that gap + projects to zero and the icons stack on each other; return + ``world_base`` lifted along screen-up by ``SCREEN_STACK_OFFSET`` so + the icons stay individually visible and clickable.""" + if tool.Blender.is_view_top_down(context): + return world_base + tool.Blender.get_screen_up_world(context) * cls.SCREEN_STACK_OFFSET + return world_top @classmethod def get_color_from_name(cls, color: GizmoColor | str) -> tuple[float, float, float]: @@ -4582,22 +5214,13 @@ class BaseParametricGizmoGroup: 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 + State-aware icons must use a static pair (open/closed) and have the + consumer pick which one to show. """ prefs = tool.Blender.get_addon_preferences() highlight_color = prefs.decorator_color_selected[:3] @@ -4607,8 +5230,6 @@ class BaseParametricGizmoGroup: 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) @@ -4618,23 +5239,30 @@ class BaseParametricGizmoGroup: 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). + return self.create_icon_gizmo("VIEW3D_GT_arc", color, operator, alpha, **operator_props) - 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 + def create_icon_gizmo_lock_pair( + self, + operator: str, + open_color: tuple[float, float, float], + closed_color: tuple[float, float, float] | None = None, + alpha: float = 0.8, + **operator_props, + ) -> tuple[bpy.types.Gizmo, bpy.types.Gizmo]: + """Create an open/closed padlock gizmo pair sharing one operator binding. - Returns: - The created arc gizmo - """ - return self.create_icon_gizmo("VIEW3D_GT_arc", color, operator, prop_path, alpha, **operator_props) + ``closed_color`` defaults to ``open_color`` for neutral pairs. Caller + hides whichever member is inappropriate for the current state, then + positions both together via ``set_icon_gizmo_pair_position`` so a + state flip can't reveal a stale pose.""" + if closed_color is None: + closed_color = open_color + open_gz = self.create_icon_gizmo("VIEW3D_GT_lock_open", open_color, operator, alpha, **operator_props) + closed_gz = self.create_icon_gizmo("VIEW3D_GT_lock_closed", closed_color, operator, alpha, **operator_props) + return open_gz, closed_gz @classmethod def is_element_type(cls, element) -> bool: @@ -4645,12 +5273,39 @@ class BaseParametricGizmoGroup: 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: + if not tool.Blender.are_viewport_gizmos_enabled(): return False + if cls.gizmo_pref_name: + prefs = tool.Blender.get_addon_preferences() + feature_prefs = getattr(prefs.gizmos, cls.gizmo_pref_name, None) + if feature_prefs is not None and not getattr(feature_prefs, "enabled", True): + 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) + if not element: + return False + # Array children are managed replicas — their parametric attributes get + # overwritten on the next ``regenerate_array``, so editing them via the + # parametric gizmos would be silently undone. Skip across every gizmo + # group (door/window/stair/wall/roof/railing/array all inherit this poll). + if tool.Blender.Modifier.is_array_child(element): + return False + if not cls.is_element_type(element): + return False + # Mutual exclusion between parametric and array edit lifecycles — running two + # finish operators against the same object would race, and the doubled + # validate/cancel icon stack reads as a UI bug. Hide this gizmo group + # while a different parametric type is in an active edit lifecycle on obj. + if cls._other_parametric_edit_active(obj): + return False + return True + + @classmethod + def _other_parametric_edit_active(cls, obj: bpy.types.Object) -> bool: + """True if any parametric type OTHER than this group's own is in an + active edit lifecycle on ``obj``.""" + return tool.Parametric.is_object_editing(obj, skip_name=getattr(cls, "gizmo_pref_name", None)) is not None def setup(self, context: bpy.types.Context) -> None: """Template method for gizmo setup. @@ -4714,18 +5369,23 @@ class BaseParametricGizmoGroup: # 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" + props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup] | None = None 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") + 1. Define class attribute `props_getter` (e.g., tool.Model.get_door_props) 2. Override this method directly + + The ``props_getter`` reference is captured at class-definition time + (early binding), so tests cannot redirect it via + ``patch.object(tool.Model, "get_X_props", ...)``. Inject a stub + callable directly when exercising dispatch in tests. """ if self.props_getter: - return getattr(tool.Model, self.props_getter)(obj) + return self.props_getter(obj) raise NotImplementedError("Subclass must define props_getter or override get_props()") def get_addon_prefs(self): @@ -4839,21 +5499,34 @@ class BaseParametricGizmoGroup: y: float, z: float, billboard_rot: Matrix, - scale: float = 0.5, + scale: float = DEFAULT_BILLBOARD_SCALE, ) -> 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): world_pos = mw @ Vector((x, y, z)) gz.matrix_basis = billboarded_at(world_pos, billboard_rot, scale) + def set_icon_gizmo_pair_position( + self, + open_name: str, + closed_name: str, + mw: Matrix, + x: float, + y: float, + z: float, + billboard_rot: Matrix, + scale: float = DEFAULT_BILLBOARD_SCALE, + ) -> None: + """Position both members of an open/closed pair at the same anchor; + write the matrix on both so a state flip can't reveal a stale pose.""" + open_gz = getattr(self, open_name, None) + closed_gz = getattr(self, closed_name, None) + if not open_gz or not closed_gz: + return + world_pos = mw @ Vector((x, y, z)) + matrix = billboarded_at(world_pos, billboard_rot, scale) + open_gz.matrix_basis = matrix + closed_gz.matrix_basis = matrix + def set_dimension_gizmo_position( self, attr_name: str, @@ -4898,30 +5571,13 @@ class BaseParametricGizmoGroup: 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 - """ + def should_hide_dimension_gizmo(self, gizmo: bpy.types.Gizmo, config: "DimensionGizmoConfig", props) -> bool: + """Hide a dimension gizmo when its modal owner is active, when the + element isn't in edit state for this attribute, or when the config + carries a custom visibility predicate that rejects ``props``. The + per-feature enable toggle is gated upstream by ``poll()``.""" 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): @@ -4948,9 +5604,20 @@ class BaseParametricGizmoGroup: 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 - ) + # Pen icon is bound to ``bim.enable_editing_parametric`` (a universal dispatcher) + # rather than the gizmo group's own enable op directly. The dispatcher receives + # this group's ``enable_editing_operator`` as ``feature_enable_op`` and: + # - plain click → fires the per-feature enable (this group's operator) + # - Shift+click → fires ``bim.enable_editing_array`` if the active element is + # an array parent (one pen icon, two behaviours; no second pen needed for arrays). + self.pen_gizmo = self.gizmos.new("VIEW3D_GT_pen") + self.pen_gizmo.use_draw_scale = False + self.pen_gizmo.color = default_color + self.pen_gizmo.color_highlight = highlight_color + self.pen_gizmo.alpha = 0.8 + pen_op = self.pen_gizmo.target_set_operator("bim.enable_editing_parametric") + pen_op.feature_enable_op = self.enable_editing_operator + self.validate_gizmo = self._setup_icon_gizmo( "VIEW3D_GT_validate", self.COLOR_GREEN, self.finish_editing_operator, highlight_color ) @@ -4958,10 +5625,31 @@ class BaseParametricGizmoGroup: "VIEW3D_GT_cancel", self.COLOR_RED, self.cancel_editing_operator, highlight_color ) + # Type-selector slot: cycle (one click advances) or pick (popup menu). + # ``self.cycle_gizmo`` is the shared instance name regardless of icon — + # consumers reposition / hide it via that attribute. ``cycle_type_operator`` + # wins if both are set (consumers shouldn't set both). if self.cycle_type_operator: self.cycle_gizmo = self._setup_icon_gizmo( "VIEW3D_GT_cycle", default_color, self.cycle_type_operator, highlight_color ) + elif self.pick_type_operator: + self.cycle_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_menu", default_color, self.pick_type_operator, highlight_color + ) + + # ARRAY button — visible during the feature edit lifecycle only (positioned by + # ``update_editing_gizmos``). Click commits the current edit and adds a + # Blender-vanilla-defaulted array (count=2, X-offset = bbox extent). The + # array gizmo group opts out via ``hide_array_button = True`` since + # adding an array to an array layer is the panel's job, not a gizmo's. + if not getattr(self, "hide_array_button", False): + self.array_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_array_all", + default_color, + "bim.add_array_from_feature_edit", + highlight_color, + ) def _make_dimension_getter(self, config: DimensionGizmoConfig): """Create getter closure for dimension gizmo.""" @@ -4987,15 +5675,16 @@ class BaseParametricGizmoGroup: return move_get def _make_dimension_setter(self, config: DimensionGizmoConfig): - """Create setter closure for dimension gizmo.""" + """Setter closure. ``min_value`` clamps only on the default + ``attr_name`` path; a custom ``apply_value`` owns its own bounding.""" if config.apply_value: - apply_fn, min_val = config.apply_value, config.min_value + apply_fn = config.apply_value def move_set(value): obj = bpy.context.active_object if not obj: return - apply_fn(self.get_props(obj), max(min_val, value)) + apply_fn(self.get_props(obj), value) return move_set @@ -5009,12 +5698,78 @@ class BaseParametricGizmoGroup: return move_set + # Fixed visual length (world units) for count gizmos. Decoupled from the + # underlying integer value so a count of 99 doesn't render as a 99-metre bar. + COUNT_GIZMO_VISUAL_LENGTH = 0.3 + + def _make_count_setter(self, config: "CountGizmoConfig"): + """Create setter closure for count gizmo. Snaps to integer step and + clamps to [min_count, max_count] before applying.""" + min_count, max_count, step = config.min_count, config.max_count, config.step + + if config.apply_value: + apply_fn = config.apply_value + + def move_set(value): + obj = bpy.context.active_object + if not obj: + return + snapped = max(min_count, min(max_count, int(round(value / step)) * step)) + apply_fn(self.get_props(obj), snapped) + + return move_set + + attr_name = config.attr_name + + def move_set(value): + obj = bpy.context.active_object + if not obj: + return + snapped = max(min_count, min(max_count, int(round(value / step)) * step)) + setattr(self.get_props(obj), attr_name, snapped) + + return move_set + + def _setup_count_gizmo(self, config: "CountGizmoConfig", highlight_color: tuple[float, float, float]) -> None: + """Configure a BIM_GT_gizmo_dimension instance to behave as an integer stepper. + + Reuses the dimension gizmo type — only configuration differs (no arrows, + no extension lines, int-snapped setter, count_formatter as text_formatter, + fixed visual length applied per-frame in ``update_dimension_gizmos``).""" + gizmo = self.gizmos.new("BIM_GT_gizmo_dimension") + gizmo.move_get_cb = self._make_dimension_getter(config) + gizmo.move_set_cb = self._make_count_setter(config) + gizmo.axis = Vector(config.axis) + gizmo.local_axis = Vector(config.axis) + gizmo.invert_delta = False + gizmo.delta_scale = config.delta_scale + gizmo.prop_name = config.prop_name + gizmo.gizmo_group = self + # Count formatter receives (props, value) like text_formatter; the + # dimension gizmo's draw path calls it once per frame. + gizmo.text_formatter = config.count_formatter or (lambda props, value: str(int(value))) + 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 = 1 + gizmo.text_alignment = TextAlignment.CENTER + # Count visual is a plain bar — no arrows, no extension lines. + gizmo.show_start_arrow = False + gizmo.show_end_arrow = False + gizmo.show_extension_lines = False + setattr(self, f"dimension_{config.attr_name}_gizmo", gizmo) + def setup_dimension_gizmos(self, context: bpy.types.Context) -> None: - """Set up dimension gizmos from dimension_gizmo_props configuration.""" + """Set up value gizmos (dimensions and counts) from dimension_gizmo_props.""" prefs = tool.Blender.get_addon_preferences() highlight_color = prefs.decorator_color_selected[:3] for config in getattr(self, "dimension_gizmo_props", []): + if isinstance(config, CountGizmoConfig): + self._setup_count_gizmo(config, highlight_color) + continue 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) @@ -5037,16 +5792,13 @@ class BaseParametricGizmoGroup: 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() - + """Update value gizmos (dimensions and counts) from dimension_gizmo_props.""" 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): + if self.should_hide_dimension_gizmo(gizmo, config, props): gizmo.hide = True continue @@ -5064,6 +5816,15 @@ class BaseParametricGizmoGroup: else: value = getattr(props, config.attr_name, 0.0) + if isinstance(config, CountGizmoConfig): + # Visual length is decoupled from the integer count — the bar + # stays at a constant world size while the label tracks ``value``. + gizmo.matrix_basis = mw @ base_matrix + gizmo._dimension_length = self.COUNT_GIZMO_VISUAL_LENGTH + gizmo._display_value = value + gizmo.select_bias = -self.COUNT_GIZMO_VISUAL_LENGTH + continue + # Use consolidated negative value handling self._apply_dimension_matrix(gizmo, mw, base_matrix, value) gizmo.show_start_arrow = config.show_start_arrow @@ -5128,7 +5889,7 @@ class BaseParametricGizmoGroup: z=icon_z, billboard_rot=billboard_rot, ) - if self.cycle_type_operator: + if self.cycle_type_operator or self.pick_type_operator: self.cycle_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cycle_gizmo) self.set_icon_gizmo_position( "cycle_gizmo", @@ -5139,15 +5900,45 @@ class BaseParametricGizmoGroup: billboard_rot=billboard_rot, scale=0.30, ) + # ARRAY button sits past the last feature-specific icon. Each + # gizmo group declares its own ``FEATURE_ICON_MAX_X`` (default + # 0.87 past the cycle slot; wall / stair override it) so the + # ARRAY button never lands on top of a rotate / tread-lock icon. + if hasattr(self, "array_gizmo"): + self.array_gizmo.hide = self.is_gizmo_hidden_by_modal(self.array_gizmo) + # 30% smaller than the editing-icon-row default (0.50 → 0.35): + # the array button is a tertiary affordance compared to the + # primary pen / validate / cancel triad, and the smaller + # footprint keeps the edit-mode row from sprawling. + self.set_icon_gizmo_position( + "array_gizmo", + mw=mw, + x=self.ICON_VALIDATE_X + self.FEATURE_ICON_MAX_X + self.ICON_ARRAY_GAP, + y=icon_y, + z=icon_z, + billboard_rot=billboard_rot, + scale=0.35, + ) else: - self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo) - self.set_icon_gizmo_position( - "pen_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot - ) + # ``hide_pen_button = True`` keeps the pen permanently hidden — for + # groups whose edit-mode entry is already provided by another widget + # in the same viewport region. ``GizmoArrayEdition`` opts in because + # its clickable ``xN`` count label (``GizmoArrayCount``) is the + # canonical entry point; surfacing a second pen next to it is the + # redundant icon the user saw in the array gizmo viewport. + if getattr(self, "hide_pen_button", False): + self.pen_gizmo.hide = True + else: + self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo) + self.set_icon_gizmo_position( + "pen_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot + ) self.validate_gizmo.hide = True self.cancel_gizmo.hide = True - if self.cycle_type_operator: + if self.cycle_type_operator or self.pick_type_operator: self.cycle_gizmo.hide = True + if hasattr(self, "array_gizmo"): + self.array_gizmo.hide = True def draw_prepare(self, context: bpy.types.Context) -> None: """Called before drawing - updates gizmos to face camera. @@ -5194,3 +5985,615 @@ class BaseParametricGizmoGroup: mw: Object's world matrix props: Element properties object """ + + +class BaseSchematicGizmoGroup(BaseParametricGizmoGroup): + """Base for parametric gizmo groups that drive a billboarded schematic preview. + + Provides: + + - Schematic-anchored ``BIM_GT_gizmo_dimension`` instances declared via + ``schematic_dimension_props``. Each dimension is laid out in + schematic-local coordinates around the schematic anchor and + billboarded to the camera, so the labelled tag reads the same size + regardless of the bound value and the camera angle. + - A GPU draw handler that renders a live mini preview of the element's + geometry near the icon row. Subclasses build the bmesh in + ``build_schematic_mesh(props)`` and the handler reuses a cached + list of local-coordinate edge pairs across redraws. + + Subclasses leave ``dimension_gizmo_props = []`` (the default here) and + populate ``schematic_dimension_props`` instead. The pen / validate / + cancel / cycle icon row inherited from the parametric base still applies. + + Decoration-only: the preview mesh is not hit-testable; clicks land on + the labelled dimensions, which carry the parametric edit semantics. + """ + + # Schematic groups don't draw in-place dimension lines; the parent's + # setup_dimension_gizmos / update_dimension_gizmos iterate this empty + # list and become no-ops. The schematic equivalents below take their place. + dimension_gizmo_props: list[DimensionGizmoConfig] = [] + + # Declarative dimension configuration consumed by ``setup_schematic_dimensions`` + # and ``update_schematic_dimensions``. Each config produces one + # ``BIM_GT_gizmo_dimension`` instance positioned at a schematic-local + # location and billboarded to the camera. The dimension's *visual* length + # is the actual value rescaled into schematic units via + # ``_compute_schematic_scale`` and floored at a minimum visible length, + # so tiny dimensions stay grabable; the *displayed* numeric label still + # shows the real value via ``text_formatter``. + schematic_dimension_props: list[DimensionGizmoConfig] = [] + + # World-unit half-extent of the schematic decoration box anchored at the + # icon row. Sliders' ``slider_position`` values are interpreted inside + # this box; subclasses scale ``build_schematic_mesh`` output to fit it. + schematic_box_size: float = 0.3 + + # Offset from the icon-row anchor (object origin + element height + + # ICON_Z_OFFSET) to the bottom-centre of the schematic, applied as + # ``billboard_rot @ schematic_anchor_offset``. The coordinate convention + # matches ``billboard_rot``: schematic-local +X → screen RIGHT, +Y → screen + # UP, +Z → toward the viewer. The default ``(0, 0.9, 0)`` lifts the + # schematic by 0.9 world-units in screen UP so it clears the validate / + # cancel icons (which sit at the icon-row anchor with scale 0.2). + schematic_anchor_offset: Vector = Vector((0.0, 0.9, 0.0)) + + # Fixed rotation applied to the schematic frame *before* billboarding, + # so the schematic appears at the same tilt regardless of camera angle. + # Default identity ⇒ flat front view. Subclasses can set a small + # rotation (e.g. ~25° around Y) to expose the depth axis, so dimensions + # along schematic-local Z have a visible on-screen extent. Useful when + # one of the bound properties is a depth/thickness whose true geometric + # direction is otherwise invisible from a flat front-facing schematic. + schematic_view_rotation: "Matrix" = Matrix.Identity(4) + + # Per-concrete-subclass draw-handler singleton. Python writes via + # ``cls._draw_handler_installed = ...`` land on the concrete class + # (not on this base), so two consumer subclasses do not collide. + _draw_handler_installed: object | None = None + + # Per-concrete-subclass cache of (schematic_cache_key → list[(Vector, + # Vector, tag)]) — schematic-local edge endpoints + feature tag, + # pre-computed once per distinct geometry shape (typically per + # ``railing_type``-like enum). The draw handler transforms the cached + # local coords with the current frame's billboard + view rotation + # rather than re-running the bmesh build pipeline; this is the + # standard Blender practice of keeping allocations out of draw + # callbacks. The cache is lazily initialised per subclass via + # ``_get_schematic_geometry_cache`` so concurrent consumers don't + # share entries. + _schematic_geometry_cache: dict | None = None + + # Maps a dimension's ``attr_name`` (e.g. "railing_diameter") to a + # feature tag carried on the schematic mesh's edges (e.g. "rail_tube"). + # When the user hovers a dimension whose ``attr_name`` is in this map, + # all edges tagged with the corresponding feature are drawn in + # ``SCHEMATIC_HIGHLIGHT_COLOR`` so the geometric part being measured + # is visually called out. Subclasses opt in by populating this dict; + # the default empty dict gives no highlight (graceful no-op). + schematic_attr_to_feature: dict[str, str] = {} + + # Per-concrete-subclass cache of the feature tag currently hovered. + # Written by ``_update_hovered_feature`` (instance-side, runs in + # ``draw_prepare``) and read by the class-level draw handler. ``None`` + # means "no dimension hovered" (default-coloured pass only). + _hovered_feature: str | None = None + + # Name of the bmesh edge string layer used to tag edges with a feature + # name. Builders write ``edge[layer] = b"rail_tube"``; the cache reads + # the same layer back on extraction. The string layer is preferred + # over an int layer + lookup table because each builder declares its + # tags in plain Python and the extraction path is symmetric. + SCHEMATIC_FEATURE_LAYER_NAME: str = "schematic_feature" + + # ── Abstract hooks ──────────────────────────────────────────────────── + + @classmethod + def build_schematic_mesh(cls, props) -> "bmesh.types.BMesh": + """Return a transient bmesh of the mini preview in schematic-local coordinates. + + Subclasses MUST implement. The returned bmesh's edges are extracted + into a cached list of local-coord ``(Vector, Vector)`` pairs by + ``_get_schematic_local_edges`` and the bmesh is freed immediately + afterward. The draw handler then transforms the cached pairs per + frame — so the bmesh is built once per distinct + ``schematic_cache_key`` value, not once per draw call. + """ + raise NotImplementedError(f"{cls.__name__} must implement build_schematic_mesh(props) -> bmesh.BMesh") + + @classmethod + def schematic_cache_key(cls, props): + """Hashable key identifying the schematic's geometry shape, or ``None`` to disable caching. + + Subclasses whose schematic depends only on a small set of discrete + (e.g. enum-like) props should return a tuple of those — the bmesh + then rebuilds only when the key changes. Returning ``None`` rebuilds + on every draw, appropriate for schematics whose proportions vary + continuously with the bound properties. + + The cached form lives in ``_schematic_geometry_cache`` and is + camera-independent: only schematic-local edge endpoints are stored, + so the cache survives camera moves and only invalidates on key + change. + """ + return None + + # ── Optional hooks ──────────────────────────────────────────────────── + + def schematic_should_show(self, props) -> bool: + """Whether the schematic preview and sliders should be visible this frame. + + Default: tied to ``props.is_editing``. Subclasses can override to + add additional gating (e.g. hide when a sibling edit mode is open). + """ + return bool(getattr(props, "is_editing", False)) + + # ── Lifecycle (overrides ``BaseParametricGizmoGroup``) ──────────────── + + def setup(self, context: bpy.types.Context) -> None: + self.setup_editing_gizmos(context) + self.setup_schematic_dimensions(context) + self.setup_element_specific_gizmos(context) + + def refresh(self, context: bpy.types.Context) -> None: + if not self.is_setup_complete(): + return + obj = context.active_object + if not obj: + return + props = self.get_props(obj) + mw = obj.matrix_world + self._prime_frame_caches(context, mw) + self.update_editing_gizmos(context, mw, props) + self.update_schematic_dimensions(context, mw, props) + self._reconcile_draw_handler(props) + self._refresh_element_specific(context, mw, props) + self._update_hovered_feature() + + def draw_prepare(self, context: bpy.types.Context) -> None: + if not self.is_setup_complete(): + return + obj = context.active_object + if not obj: + return + props = self.get_props(obj) + mw = obj.matrix_world + self._prime_frame_caches(context, mw) + self.update_editing_gizmos(context, mw, props) + self.update_schematic_dimensions(context, mw, props) + self._reconcile_draw_handler(props) + self._refresh_element_specific(context, mw, props) + self._update_hovered_feature() + + def _update_hovered_feature(self) -> None: + """Record which feature tag the user is currently hovering on. + + Walks the group's gizmos for the first ``is_highlight=True`` + dimension whose ``schematic_attr_name`` maps into + ``schematic_attr_to_feature``, and writes the corresponding tag + onto the concrete class (so the class-level draw handler can + pick it up). ``None`` is written when nothing eligible is + hovered. Cheap walk — runs once per frame, no allocations. + """ + cls = type(self) + attr_to_feature = cls.schematic_attr_to_feature + if not attr_to_feature: + cls._hovered_feature = None + return + for gz in self.gizmos: + if not getattr(gz, "is_highlight", False): + continue + attr_name = getattr(gz, "schematic_attr_name", None) + if attr_name is None: + continue + feature = attr_to_feature.get(attr_name) + if feature is not None: + cls._hovered_feature = feature + return + cls._hovered_feature = None + + # ── Dimension wiring (schematic-anchored ``BIM_GT_gizmo_dimension`` lines) ── + + # Fixed visual length (as a fraction of ``schematic_box_size``) for every + # Schematic dimension bars render as constant-width labelled tags; the + # value reads from the text label, not bar length. Decouples readability + # from value magnitude — a 5mm thickness and a 5m height are equally + # clickable. Drag distance still maps 1:1 to the property's world units. + SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO: float = 0.6 + + def setup_schematic_dimensions(self, context: bpy.types.Context) -> None: + """Create one ``BIM_GT_gizmo_dimension`` per ``DimensionGizmoConfig``.""" + prefs = tool.Blender.get_addon_preferences() + highlight_color = prefs.decorator_color_selected[:3] + + for config in self.schematic_dimension_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) + # Non-zero initial axis; per-frame refresh overwrites with the + # billboarded direction. + gizmo.axis = Vector(config.axis) + # No ``local_axis``: schematic drags must follow the billboarded + # bar (screen-up for a vertical bar), not the object-local axis. + gizmo.invert_delta = config.invert_delta + gizmo.delta_scale = config.delta_scale + gizmo.prop_name = config.prop_name + 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 + gizmo.schematic_attr_name = config.attr_name + setattr(self, f"schematic_dim_{config.attr_name}_gizmo", gizmo) + + def update_schematic_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None: + """Position and size each schematic-anchored dimension gizmo.""" + billboard_rot = self._frame_billboard_rot + view_rotation = self.schematic_view_rotation + anchor = self._compute_schematic_anchor(props, mw, billboard_rot) + should_show = self.schematic_should_show(props) + default_length = self.schematic_box_size * self.SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO + + for config in self.schematic_dimension_props: + gizmo = getattr(self, f"schematic_dim_{config.attr_name}_gizmo", None) + if gizmo is None: + continue + + if not should_show: + gizmo.hide = True + continue + if config.visibility_condition is not None and not config.visibility_condition(props): + gizmo.hide = True + continue + if self.is_gizmo_hidden_by_modal(gizmo): + gizmo.hide = True + continue + gizmo.hide = False + + # Freeze geometry transforms while a modal is active so an + # orbit-during-drag can't shift the drag direction under the + # user's hand. + if getattr(gizmo, "is_modal", False): + continue + + local_offset = Vector() + if config.matrix_position is not None: + local_offset = Vector(config.matrix_position(props)) + gizmo.matrix_basis = self._schematic_world_matrix( + anchor, billboard_rot, config.axis, local_offset, view_rotation + ) + + # Drag axis = visual bar direction; keep aligned with the on-screen + # bar even when it points partly into screen depth. + gizmo.axis = (billboard_rot @ view_rotation @ Vector(config.axis)).normalized() + + visible_length = ( + config.schematic_visible_length if config.schematic_visible_length is not None else default_length + ) + gizmo.set_dimension_length(visible_length) + gizmo.show_start_arrow = config.show_start_arrow + gizmo.show_end_arrow = config.show_end_arrow + + # ── Schematic anchor + draw handler lifecycle ───────────────────────── + + def _compute_schematic_anchor(self, props, mw: Matrix, billboard_rot: Matrix) -> Vector: + """World-space anchor of the schematic decoration box (instance entry point).""" + return self.compute_schematic_anchor( + mw, + self.get_element_height(props), + self.ICON_VALIDATE_X, + self.ICON_Z_OFFSET, + billboard_rot, + self.schematic_anchor_offset, + ) + + @staticmethod + def compute_schematic_anchor( + mw: Matrix, + element_height: float, + icon_x: float, + icon_z_offset: float, + billboard_rot: Matrix, + schematic_offset: Vector, + ) -> Vector: + """Schematic-anchor world position: icon-row origin + the schematic + offset rotated into the screen frame. + + The anchor itself stays billboard-aligned regardless of + ``schematic_view_rotation``; tilts are applied to the contents + downstream so the anchored frame stays stable on screen.""" + icon_world = mw @ Vector((icon_x, 0.0, element_height + icon_z_offset)) + return icon_world + billboard_rot @ Vector(schematic_offset) + + @staticmethod + def _schematic_world_matrix( + anchor: Vector, + billboard_rot: Matrix, + axis: tuple[float, float, float], + local_position: tuple[float, float, float] | Vector, + view_rotation: Matrix | None = None, + ) -> Matrix: + """``matrix_basis`` for a schematic-anchored gizmo. + + Translates to ``anchor + billboard_rot @ view_rotation @ local_position`` + and rotates +X to the schematic-local ``axis``.""" + if view_rotation is None: + view_rotation = Matrix.Identity(4) + local_offset = view_rotation @ Vector(local_position) + world_pos = anchor + billboard_rot @ local_offset + axis_world = (billboard_rot @ view_rotation @ Vector(axis)).normalized() + x_to_axis = Vector((1, 0, 0)).rotation_difference(axis_world).to_matrix().to_4x4() + return Matrix.Translation(world_pos) @ x_to_axis + + def _reconcile_draw_handler(self, props) -> None: + """Install or remove the GPU draw handler to match ``schematic_should_show``.""" + if self.schematic_should_show(props): + self._install_draw_handler() + else: + self._uninstall_draw_handler() + + @classmethod + def _get_schematic_geometry_cache(cls) -> dict: + """Return the per-concrete-subclass schematic-geometry cache, creating it on first access. + + Subclass attribute writes via ``cls._schematic_geometry_cache = ...`` + land on the concrete class (not on this base), so two consumer + subclasses keep independent caches. The lazy ``__dict__`` check + ensures each subclass starts with its own empty dict rather than + inheriting (and mutating) the base's. + """ + if "_schematic_geometry_cache" not in cls.__dict__ or cls._schematic_geometry_cache is None: + cls._schematic_geometry_cache = {} + return cls._schematic_geometry_cache + + @classmethod + def _get_schematic_local_edges(cls, props) -> "list[tuple[Vector, Vector, str | None]]": + """Return the schematic's edges as schematic-local ``(v0, v1, tag)`` triples. + + ``tag`` is the feature tag stored on the bmesh edge string layer + named by ``SCHEMATIC_FEATURE_LAYER_NAME`` (empty bytes → ``None``). + Builders that don't tag any edges produce all-``None`` tags; the + draw handler then takes the default-only path. + + Hits the per-subclass cache when ``schematic_cache_key(props)`` is + not ``None`` — the bmesh is built only on cache miss. The cached + list contains only local coordinates + tag strings, so it stays + valid across camera moves; the draw handler applies per-frame + transforms (anchor, billboard rotation, view rotation) at render + time. + + Keeping the bmesh allocation off the draw path is the standard + Blender practice — see the ``ProfileDecorator`` pattern, which + likewise caches its shader and rebuilds geometry only on + state-change rather than per draw call. + """ + key = cls.schematic_cache_key(props) + cache = cls._get_schematic_geometry_cache() + if key is not None and key in cache: + return cache[key] + bm = cls.build_schematic_mesh(props) + try: + feat_layer = bm.edges.layers.string.get(cls.SCHEMATIC_FEATURE_LAYER_NAME) + edges: list[tuple[Vector, Vector, str | None]] = [] + for e in bm.edges: + v0 = Vector(e.verts[0].co) + v1 = Vector(e.verts[1].co) + if feat_layer is None: + tag: str | None = None + else: + raw = e[feat_layer] + tag = raw.decode("utf-8") if raw else None + edges.append((v0, v1, tag)) + finally: + bm.free() + if key is not None: + cache[key] = edges + return edges + + @classmethod + def _install_draw_handler(cls) -> None: + """Register a class-level ``POST_VIEW`` handler on ``SpaceView3D``. + + Idempotent. The class attribute write lands on the concrete subclass + (not on this base), so two schematic consumers (railing, roof, …) + keep independent handles. + """ + if cls._draw_handler_installed is not None: + return + cls._draw_handler_installed = bpy.types.SpaceView3D.draw_handler_add( + cls._schematic_draw_callback, (cls,), "WINDOW", "POST_VIEW" + ) + + @classmethod + def _uninstall_draw_handler(cls) -> None: + """Remove the schematic draw handler if installed. Idempotent.""" + if cls._draw_handler_installed is None: + return + bpy.types.SpaceView3D.draw_handler_remove(cls._draw_handler_installed, "WINDOW") + cls._draw_handler_installed = None + + @classmethod + def _props_for_active(cls): + """``(obj, props)`` for the active+selected object, or ``(None, None)``.""" + obj = tool.Blender.get_active_object(is_selected=True) + if obj is None or not cls.props_getter: + return None, None + props = cls.props_getter(obj) + return obj, props + + @classmethod + def _schematic_draw_callback(cls, owner_cls) -> None: + """GPU callback that renders the schematic mesh as wireframe. + + Self-uninstalls when the active object has no editable schematic props. + Per-frame: rebuilds the bmesh from props, transforms verts into the + schematic frame, batches as line segments via ``POLYLINE_UNIFORM_COLOR``. + """ + obj, props = owner_cls._props_for_active() + if obj is None or props is None or not owner_cls.schematic_should_show_class(props): + owner_cls._uninstall_draw_handler() + return + + context = bpy.context + region = getattr(context, "region", None) + rv3d = getattr(context, "region_data", None) + if region is None or rv3d is None: + return + + try: + local_edges = owner_cls._get_schematic_local_edges(props) + except Exception: + # A subclass build that raises would otherwise crash the viewport + # on every redraw. Drop the handler so the user sees a missing + # schematic instead of a broken Blender; the next refresh will + # try again if conditions allow. + owner_cls._uninstall_draw_handler() + return + + if not local_edges: + return + + mw = obj.matrix_world + billboard_rot = get_billboard_rotation(context) + anchor = owner_cls.compute_schematic_anchor( + mw, + owner_cls._get_element_height_class(props), + owner_cls.ICON_VALIDATE_X, + owner_cls.ICON_Z_OFFSET, + billboard_rot, + owner_cls.schematic_anchor_offset, + ) + + view_rotation = owner_cls.schematic_view_rotation + hovered = getattr(owner_cls, "_hovered_feature", None) + default_segments: list[tuple[float, float, float]] = [] + highlight_segments: list[tuple[float, float, float]] = [] + for v0_local, v1_local, tag in local_edges: + a = tuple(anchor + billboard_rot @ view_rotation @ v0_local) + b = tuple(anchor + billboard_rot @ view_rotation @ v1_local) + if hovered is not None and tag == hovered: + highlight_segments.append(a) + highlight_segments.append(b) + else: + default_segments.append(a) + default_segments.append(b) + + shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + shader.bind() + shader.uniform_float("lineWidth", owner_cls.SCHEMATIC_LINE_WIDTH) + shader.uniform_float("viewportSize", (region.width, region.height)) + if default_segments: + shader.uniform_float("color", owner_cls.SCHEMATIC_LINE_COLOR) + batch_for_shader(shader, "LINES", {"pos": default_segments}).draw(shader) + if highlight_segments: + shader.uniform_float("color", owner_cls.SCHEMATIC_HIGHLIGHT_COLOR) + batch_for_shader(shader, "LINES", {"pos": highlight_segments}).draw(shader) + + @classmethod + def schematic_should_show_class(cls, props) -> bool: + """Class-level visibility gate. Mirror any override of the instance form.""" + return bool(getattr(props, "is_editing", False)) + + @classmethod + def _get_element_height_class(cls, props) -> float: + return getattr(props, "overall_height", getattr(props, "height", 1.0)) + + # ── Visual constants ───────────────────────────────────────────────── + + SCHEMATIC_LINE_COLOR: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 0.85) + # Warm amber, opaque, distinguishable against the default white line + # colour and against most Blender themes. Used to overdraw the subset + # of edges tagged with the hovered dimension's feature. + SCHEMATIC_HIGHLIGHT_COLOR: tuple[float, float, float, float] = (1.0, 0.7, 0.2, 0.95) + SCHEMATIC_LINE_WIDTH: float = 1.5 + + +class BaseIconActionGroup(BillboardingGizmoGroupMixin): + """Base for gizmo groups that emit clickable icon-action gizmos. + + Action gizmos invoke an operator on click and have no associated state — + copy Z rotation, snap to host, align to grid, etc. Each subclass declares + ``action_configs: list[IconActionConfig]`` and one icon is emitted per + config, stacked horizontally and billboarded above the active object's + bounding box. + + Override ``is_eligible_object`` to gate when the group polls in. The + default eligibility is "active object is an IFC element"; subclasses + typically also require a selection cardinality. + + The pen / validate / cancel icon row from ``BaseParametricGizmoGroup`` + polls when **exactly one** object is selected, so action gizmos that + require ``len >= 2`` are mutually exclusive with parametric editing — + there is no icon-row overlap in practice. + """ + + action_configs: ClassVar[list[IconActionConfig]] = [] + + # Layout constants. Icons appear above the active object's bounding box, + # billboarded toward the camera. Tweak per-subclass if a feature needs a + # different anchor. ICON_SCALE matches the validate/cancel cycle scale + # used by BaseParametricGizmoGroup at ICON_VALIDATE_X (0.375 ≈ 75% of + # the default gizmo size) so the action icons sit at the same visual + # weight as the parametric-edit icon row. + ICON_ROW_Z_OFFSET = 0.5 + ICON_SPACING_X = 0.4 + ICON_SCALE = 0.375 + + @classmethod + def is_eligible_object(cls, obj: bpy.types.Object) -> bool: + """Subclass override. Default: any IFC element. + + Subclasses commonly add selection-count or IFC-class filters.""" + return tool.Ifc.get_entity(obj) is not None + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + obj = tool.Blender.get_active_object(is_selected=True) + if obj is None: + return False + if not tool.Blender.are_viewport_gizmos_enabled(): + return False + return cls.is_eligible_object(obj) + + def setup(self, context: bpy.types.Context) -> None: + prefs = tool.Blender.get_addon_preferences() + default_color = tuple(prefs.decorations_colour[:3]) + highlight_color = tuple(prefs.decorator_color_selected[:3]) + for config in self.action_configs: + gizmo = self.setup_icon_gizmo(config.icon, default_color, highlight_color, config.operator) + setattr(self, f"action_{config.name}_gizmo", gizmo) + + def get_icon_anchor(self, context: bpy.types.Context) -> Vector | None: + obj = context.active_object + if obj is None: + return None + z_top = max((c[2] for c in obj.bound_box), default=0.0) + return obj.matrix_world @ Vector((0.0, 0.0, z_top + self.ICON_ROW_Z_OFFSET)) + + def position_gizmos(self, context: bpy.types.Context) -> None: + obj = context.active_object + if obj is None: + return + anchor = self.get_icon_anchor(context) + if anchor is None: + return + billboard_rot = get_billboard_rotation(context) + # World-X spacing keeps a billboarded icon row coherent regardless + # of anchor object rotation. + for i, config in enumerate(self.action_configs): + gizmo = getattr(self, f"action_{config.name}_gizmo", None) + if gizmo is None: + continue + if config.visibility_condition is not None and not config.visibility_condition(obj): + gizmo.hide = True + continue + gizmo.hide = False + pos = anchor + Vector((i * self.ICON_SPACING_X, 0.0, 0.0)) + gizmo.matrix_basis = billboarded_at(pos, billboard_rot, scale=self.ICON_SCALE)