diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index f33c90fc6f..ff916bf8a9 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -46,6 +46,7 @@ from bonsai.bim.module.model.decorator import ( BoundingBoxDecorator, SlabDirectionDecorator, WallAxisDecorator, + WallFilletPreviewDecorator, ) from bonsai.bim.module.model.preview_base import discard_pending_previews from bonsai.bim.module.nest.decorator import NestDecorator @@ -462,6 +463,7 @@ def _install_viewport_overlays() -> None: NestDecorator.uninstall() WallAxisDecorator.uninstall() SlabDirectionDecorator.uninstall() + WallFilletPreviewDecorator.uninstall() uninstall_decorator_cache_handlers() try: if georeference_props.should_visualise: @@ -476,6 +478,10 @@ def _install_viewport_overlays() -> None: SlabDirectionDecorator.install(bpy.context) if model_props.show_bounding_box: BoundingBoxDecorator.install(bpy.context) + # Always-installed: draw() self-polls on Scene.BIMPreviewProperties. + # wall_fillet.is_active, so installation has no cost when no preview + # is open. No corresponding addon-preference toggle. + WallFilletPreviewDecorator.install(bpy.context) finally: install_decorator_cache_handlers() 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..071ede7ac3 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,40 @@ 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) +def get_screen_up(billboard_rot: Matrix) -> Vector: + """Camera's screen-up direction in world space — local +Y of the billboard + rotation. Use to lift a gizmo above an anchor in a way that stays + perpendicular to the view plane (world +Z collapses to zero on-screen in + top-down view and lands lifted gizmos on top of their anchors).""" + return billboard_rot @ Vector((0.0, 1.0, 0.0)) + + +# 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 +1710,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 +1721,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 +1754,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 +2334,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 +2409,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 +2479,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 +2487,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 +2795,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 +3092,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 +3375,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 +3398,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 +3439,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 +3462,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 +3479,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 +3779,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 +3806,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 +3854,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 +3874,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 +3882,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 +3890,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 +3898,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 +4011,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 +4020,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 +4064,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 +4133,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 +4149,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 +4170,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 +4213,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 +4244,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 +4308,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 +4769,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 +4901,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 +4920,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 +5222,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 +5238,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 +5247,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 +5281,47 @@ 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 + # Hide every parametric gizmo while any preview is open — the preview + # is the only interactive surface in that mode, sister gizmos would + # compete for screen space and let the user trigger mutations that + # would race the preview's in-progress draft. + from bonsai.bim.module.model import preview_base + + if preview_base.any_preview_active(context): + 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 +5385,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 +5515,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 +5587,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 +5620,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 +5641,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 +5691,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 +5714,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 +5808,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 +5832,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 +5905,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 +5916,34 @@ class BaseParametricGizmoGroup: billboard_rot=billboard_rot, scale=0.30, ) + # Array gizmo integration is in-progress: the icon binds to + # bim.add_array_from_feature_edit but the array-from-parametric-draft + # operator + per-feature gizmo positioning haven't fully landed. + # Force-hide the icon while parametric-item editing is active to + # keep the user from triggering a half-wired add-array flow. Drop + # this gate when array integration completes. + if hasattr(self, "array_gizmo"): + self.array_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 - ) + # ``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. @@ -5157,6 +5953,8 @@ class BaseParametricGizmoGroup: customize dimension gizmo positioning, and _refresh_element_specific() to re-billboard element-specific gizmos per frame. """ + if not self.is_setup_complete(): + return obj = context.active_object if not obj: return @@ -5194,3 +5992,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) diff --git a/src/bonsai/bonsai/bim/module/geometry/data.py b/src/bonsai/bonsai/bim/module/geometry/data.py index 05344ab87e..481426d25b 100644 --- a/src/bonsai/bonsai/bim/module/geometry/data.py +++ b/src/bonsai/bonsai/bim/module/geometry/data.py @@ -44,8 +44,12 @@ class ViewportData: @classmethod def load(cls): - cls.is_loaded = True + # Populate data BEFORE flipping is_loaded so a raising ``mode()`` + # call doesn't leave the class half-loaded (flag set, dict empty). + # Subsequent items-callback invocations skip load() on a True flag + # and would hit ``cls.data["mode"]`` → KeyError. cls.data = {"mode": cls.mode()} + cls.is_loaded = True @classmethod def mode(cls) -> tool.Blender.BLENDER_ENUM_ITEMS: diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 0b7748fe72..68f3724d6d 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -60,6 +60,7 @@ import bonsai.core.root import bonsai.core.spatial import bonsai.tool as tool from bonsai.bim.ifc import IfcStore +from bonsai.bim.module.model import preview_base from bonsai.bim.module.model.decorator import ProfileDecorator if TYPE_CHECKING: @@ -2228,6 +2229,8 @@ class OverrideEscape(bpy.types.Operator): bpy.ops.bim.hide_all_openings() elif tool.Aggregate.get_aggregate_props().in_aggregate_mode: bpy.ops.bim.disable_aggregate_mode() + elif preview_base.try_cancel_active_preview(context): + pass elif active_object := context.active_object: if tool.Blender.Modifier.try_canceling_editing_modifier_parameters_or_path(active_object): pass diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 211de03879..5e2ef55e5c 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -61,6 +61,8 @@ classes = ( array.Input3DCursorXArray, array.Input3DCursorYArray, array.Input3DCursorZArray, + array.EnableEditingParametric, + array.AddArrayFromFeatureEdit, product.AddDefaultType, product.AddEmptyType, product.AddOccurrence, @@ -91,7 +93,10 @@ classes = ( wall.GizmoWallAddOpening, wall.GizmoWallEdition, wall.GizmoWallExtendVertically, + wall.GizmoWallFilletPreview, + wall.GizmoWallFilletReedit, wall.GizmoWallJoinIntersection, + wall.GizmoWallUnjoinSingle, wall.JoinWallsIntersection, wall.MergeWall, wall.OffsetWalls, @@ -100,7 +105,13 @@ classes = ( wall.SplitWall, wall.SplitWallAtCursor, wall.ToggleWallOpenings, + wall.UnjoinWallPathConnection, wall.UnjoinWalls, + wall.EnableWallFilletPreview, + wall.FinishWallFilletPreview, + wall.CancelWallFilletPreview, + wall.EnableWallFilletPreviewFromCorner, + wall.CreateWallFillet, opening.AddBoolean, opening.CloneOpening, opening.EditOpenings, @@ -161,6 +172,8 @@ classes = ( prop.BIMWallProperties, prop.BIMPolylineProperties, prop.BIMExternalParametricGeometryProperties, + prop.BIMWallFilletPreviewProperties, + prop.BIMPreviewProperties, ui.BIM_PT_array, ui.BIM_PT_stair, ui.BIM_PT_wall, @@ -291,6 +304,7 @@ def register(): bpy.types.Object.BIMExternalParametricGeometryProperties = bpy.props.PointerProperty( type=prop.BIMExternalParametricGeometryProperties ) + bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties) bpy.types.VIEW3D_MT_add.prepend(ui.add_menu) bpy.app.handlers.load_post.append(handler.load_post) @@ -315,6 +329,7 @@ def unregister(): del bpy.types.Object.BIMSverchokProperties tool.Parametric.unregister_object_properties() del bpy.types.Object.BIMExternalParametricGeometryProperties + del bpy.types.Scene.BIMPreviewProperties bpy.app.handlers.load_post.remove(handler.load_post) bpy.types.VIEW3D_MT_add.remove(ui.add_menu) diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index dd54bf0ab3..e4bfb8fedd 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -379,3 +379,129 @@ class Input3DCursorZArray(bpy.types.Operator): else: props.z = cursor.location.z - obj.location.z return {"FINISHED"} + + +class EnableEditingParametric(bpy.types.Operator): + """Pen-icon dispatcher: fires the gizmo group's per-feature edit operator. + + Bound to every parametric gizmo group's pen icon. The gizmo group's own + ``enable_editing_operator`` (``bim.enable_editing_door``, ``…_wall``, …) + is passed as ``feature_enable_op`` at setup time and invoked here. The + indirection lets one gizmo class serve all features without per-feature + subclasses.""" + + bl_idname = "bim.enable_editing_parametric" + bl_label = "Enable Editing" + bl_description = "Edit this object's parameters" + bl_options = {"REGISTER", "UNDO"} + + feature_enable_op: bpy.props.StringProperty( + default="", + description="Operator bl_idname to invoke (e.g., 'bim.enable_editing_door').", + ) + + def execute(self, context): + # Malformed ``feature_enable_op`` (missing dot) would otherwise crash + # the unpack with ValueError; treat the same as the empty-string case. + parts = self.feature_enable_op.split(".", 1) + if len(parts) != 2: + return {"CANCELLED"} + domain, opname = parts + return getattr(getattr(bpy.ops, domain), opname)("INVOKE_DEFAULT") + + +class AddArrayFromFeatureEdit(bpy.types.Operator, tool.Ifc.Operator): + """Commit any in-progress feature edit and add an array with + gizmo-friendly defaults (count=2, offset = bbox extent along the axis). + + Modifier-aware: plain click → X, Shift → Y, Ctrl → Z. Callers can pass + ``axis="X"`` via EXEC_DEFAULT to bypass the modifier read. + + All three chained operators (feature finish + add_array + enable_editing) + run inside one transaction for a single undo step.""" + + bl_idname = "bim.add_array_from_feature_edit" + bl_label = "Add Array" + bl_description = ( + "Click: add an array along X.\n" "Shift+Click: add an array along Y.\n" "Ctrl+Click: add an array along Z" + ) + bl_options = {"REGISTER", "UNDO"} + + axis: bpy.props.EnumProperty( + name="Offset Axis", + items=[ + ("X", "X", "Offset along the object's X axis (bbox X extent)"), + ("Y", "Y", "Offset along the object's Y axis (bbox Y extent)"), + ("Z", "Z", "Offset along the object's Z axis (bbox Z extent)"), + ], + default="X", + ) + + # Minimum offset to use when the object's bbox extent is tiny — prevents + # the second instance from visually overlapping the parent on small + # annotations / openings (0.3m ≈ a clearly-separated next-instance distance). + MIN_DEFAULT_OFFSET = 0.3 + + def invoke(self, context, event): + # Modifier-aware axis pick: X by default, Shift → Y, Ctrl → Z. + if event.shift: + self.axis = "Y" + elif event.ctrl: + self.axis = "Z" + else: + self.axis = "X" + return self.execute(context) + + def _execute(self, context): + obj = context.active_object + if obj is None: + return {"CANCELLED"} + # Commit any in-progress parametric edit lifecycle on this object first — the + # user expects "Add Array" to also finalise whatever they were editing + # so they don't lose their draft changes. + editing = tool.Parametric.is_object_editing(obj, skip_name="array") + if editing is not None: + finish_op_name = editing.finish_op.removeprefix("bim.") + getattr(bpy.ops.bim, finish_op_name)("INVOKE_DEFAULT") + # Bounding-box derived offset along the chosen axis, converted from + # Blender SI (meters) to IFC project units (which is what + # ``BBIM_Array.Data`` stores; the regenerator multiplies by + # unit_scale on the way out). + axis_idx = "XYZ".index(self.axis) + if obj.bound_box: + bbox_extent_si = max(c[axis_idx] for c in obj.bound_box) - min(c[axis_idx] for c in obj.bound_box) + else: + bbox_extent_si = 1.0 + bbox_extent_si = max(bbox_extent_si, self.MIN_DEFAULT_OFFSET) + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + offset_project = bbox_extent_si / si_conversion if si_conversion else bbox_extent_si + add_kwargs = {"count": 2, "x": 0.0, "y": 0.0, "z": 0.0} + add_kwargs[self.axis.lower()] = offset_project + result = bpy.ops.bim.add_array(**add_kwargs) + if result != {"FINISHED"}: + return result + # Restore selection to just the parent. ``regenerate_array`` calls + # ``tool.Geometry.duplicate_ifc_objects`` which leaves the newly-created + # child selected alongside the parent. The edit-lifecycle gizmos poll on a + # single-selected parent, so with both selected the gizmos wouldn't + # surface and "ARRAY → enter edit" would feel broken. + tool.Blender.select_and_activate_single_object(context, active_object=obj) + # Chain straight into array edit for the newly-added layer (always the + # last entry in the pset's Data list, by AddArray's append semantics). + # The user's expectation after clicking ARRAY is "I want to tweak this + # array now" — entering edit mode immediately collapses the 2-click + # discover-then-edit flow into one. + element = tool.Ifc.get_entity(obj) + if element is None: + return {"FINISHED"} + data_text = ifcopenshell.util.element.get_pset(element, "BBIM_Array", "Data") + if not data_text: + return {"FINISHED"} + try: + layers = json.loads(data_text) + except (ValueError, TypeError): + return {"FINISHED"} + if not layers: + return {"FINISHED"} + bpy.ops.bim.enable_editing_array("INVOKE_DEFAULT", item=len(layers) - 1) + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 49090e2c9f..c2cdab4512 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -108,7 +108,7 @@ class ProfileDecorator: obj = context.active_object - if obj.mode != "EDIT": + if obj is None or obj.mode != "EDIT": if exit_edit_mode_callback: ProfileDecorator.uninstall() exit_edit_mode_callback() @@ -2029,3 +2029,151 @@ class BoundingBoxDecorator: else: co1.y += y_overlap / 2 + min_spacing co2.y -= y_overlap / 2 + min_spacing + + +def _stroke_lines_alpha( + context: bpy.types.Context, + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]], + color_rgb: tuple[float, float, float], + line_width: float, + line_alpha: float, +) -> None: + """Render ``segments`` (a list of ``(start, end)`` tuples) as one + anti-aliased LINES batch in world space. Early-returns when + ``context.region`` is unavailable (e.g. when called from a + ``_RestrictContext``).""" + if not segments: + return + verts: list[tuple[float, float, float]] = [] + indices: list[tuple[int, int]] = [] + for start, end in segments: + base = len(verts) + verts.append(tuple(start)) + verts.append(tuple(end)) + indices.append((base, base + 1)) + if not tool.Blender.validate_shader_batch_data(verts, indices): + return + region = getattr(context, "region", None) + if region is None: + return + shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + shader.bind() + shader.uniform_float("viewportSize", (region.width, region.height)) + shader.uniform_float("lineWidth", line_width) + shader.uniform_float("color", (*color_rgb, line_alpha)) + batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices) + gpu.state.blend_set("ALPHA") + batch.draw(shader) + gpu.state.blend_set("NONE") + + +class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator): + """GPU preview lines for the wall-fillet flow. + + Polls on ``scene.BIMPreviewProperties.wall_fillet.is_active`` and renders + the leg projections + arc + radial construction lines returned by + ``tool.Wall.compute_wall_fillet_geometry``. The two leg lines show how + each wall will be shortened to its tangent point; the arc approximates + the rounded corner; the two construction lines (arc center to each + tangent point) visually pin the radius. + + Installed once per Blender session from ``bim/handler.py:load_post`` + and uninstalled in ``bim/module/model/__init__.py:unregister``.""" + + LINE_WIDTH_LEG = 1.5 + LINE_WIDTH_ARC = 2.5 + LINE_WIDTH_CONSTRUCTION = 1.0 + LINE_ALPHA = 0.7 + CONSTRUCTION_ALPHA = 0.4 + + def draw(self, context: bpy.types.Context) -> None: + scene = context.scene + preview_props = getattr(scene, "BIMPreviewProperties", None) + props = preview_props.wall_fillet if preview_props is not None else None + if props is None or not props.is_active: + return + ifc_file = tool.Ifc.get() + if ifc_file is None: + return + try: + wall_a = ifc_file.by_id(props.wall_a_id) + wall_b = ifc_file.by_id(props.wall_b_id) + except Exception: + return + wall_a_obj = tool.Ifc.get_object(wall_a) if wall_a else None + wall_b_obj = tool.Ifc.get_object(wall_b) if wall_b else None + if wall_a_obj is None or wall_b_obj is None: + return + + geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, props.radius) + if geom is None: + return + + prefs = tool.Blender.get_addon_preferences() + warning_color = tuple(prefs.decorator_color_error[:3]) + + if not geom["valid"]: + # Degenerate geometry paints red: invalid_radius shows legs+arc + # past the wall ends; invalid_axes shows the parallel/collinear + # axes. + if geom.get("invalid_radius"): + tangent_a = geom.get("tangent_a") + tangent_b = geom.get("tangent_b") + ref_a = tool.Wall.get_world_reference_line(wall_a_obj) + ref_b = tool.Wall.get_world_reference_line(wall_b_obj) + if tangent_a is not None and tangent_b is not None and ref_a is not None and ref_b is not None: + far_a = self._far_endpoint(ref_a, geom["intersection"]) + far_b = self._far_endpoint(ref_b, geom["intersection"]) + legs = [ + (tuple(far_a), tuple(tangent_a)), + (tuple(far_b), tuple(tangent_b)), + ] + _stroke_lines_alpha(context, legs, warning_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA) + arc = geom.get("arc") or [] + if len(arc) >= 2: + arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)] + _stroke_lines_alpha(context, arc_segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA) + elif geom.get("invalid_axes"): + axes = geom["invalid_axes"] + segments = [(tuple(a), tuple(b)) for a, b in axes] + _stroke_lines_alpha(context, segments, warning_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA) + return + + leg_color = tuple(prefs.decorations_colour[:3]) + arc_color = tuple(prefs.decorator_color_selected[:3]) + + # Resolved against the IFC reference line, not mesh bounds, so trimmed + # walls and openings don't shift the leg endpoints. + ref_a = tool.Wall.get_world_reference_line(wall_a_obj) + ref_b = tool.Wall.get_world_reference_line(wall_b_obj) + if ref_a is not None and ref_b is not None and geom["intersection"] is not None: + far_a = self._far_endpoint(ref_a, geom["intersection"]) + far_b = self._far_endpoint(ref_b, geom["intersection"]) + legs = [ + (tuple(far_a), tuple(geom["tangent_a"])), + (tuple(far_b), tuple(geom["tangent_b"])), + ] + _stroke_lines_alpha(context, legs, leg_color, self.LINE_WIDTH_LEG, self.LINE_ALPHA) + + arc = geom["arc"] + if len(arc) >= 2: + arc_segments = [(tuple(arc[i]), tuple(arc[i + 1])) for i in range(len(arc) - 1)] + _stroke_lines_alpha(context, arc_segments, arc_color, self.LINE_WIDTH_ARC, self.LINE_ALPHA) + + # Dim construction lines from arc_center to each tangent point so + # the radius reads as concrete during drag. + arc_center = geom.get("arc_center") + if arc_center is not None: + construction = [ + (tuple(arc_center), tuple(geom["tangent_a"])), + (tuple(arc_center), tuple(geom["tangent_b"])), + ] + _stroke_lines_alpha(context, construction, arc_color, self.LINE_WIDTH_CONSTRUCTION, self.CONSTRUCTION_ALPHA) + + @staticmethod + def _far_endpoint(reference_line, intersection): + """Endpoint of ``reference_line`` furthest from ``intersection``.""" + p1, p2 = reference_line + d1 = (p1.x - intersection[0]) ** 2 + (p1.y - intersection[1]) ** 2 + (p1.z - intersection[2]) ** 2 + d2 = (p2.x - intersection[0]) ** 2 + (p2.y - intersection[1]) ** 2 + (p2.z - intersection[2]) ** 2 + return p2 if d2 >= d1 else p1 diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index d6a619f429..6ccdf23c97 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -707,8 +707,8 @@ class CycleDoorType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixin) bl_label = "Cycle Door Type" bl_options = {"REGISTER", "UNDO"} - element_checker = "is_door" - props_getter = "get_door_props" + element_checker = tool.Parametric.is_door + props_getter = tool.Model.get_door_props type_literal = tool.Model.DoorType type_attr = "door_type" @@ -835,7 +835,7 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): ), ] - props_getter = "get_door_props" + props_getter = tool.Model.get_door_props gizmo_pref_name = "door" @classmethod @@ -866,13 +866,11 @@ class GizmoDoorEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): self.gizmo_door_type = self.create_arc_gizmo( special_color, "bim.toggle_door_swing", - prop_path="BIMDoorProperties.door_type", flip_geometry=False, ) self.gizmo_flip_arc = self.create_arc_gizmo( inactive_color, "bim.toggle_door_swing", - prop_path="BIMDoorProperties.door_type", flip_geometry=True, flip_local_axes="XY", ) diff --git a/src/bonsai/bonsai/bim/module/model/preview_base.py b/src/bonsai/bonsai/bim/module/model/preview_base.py index e99aadc2f4..13cde4e68a 100644 --- a/src/bonsai/bonsai/bim/module/model/preview_base.py +++ b/src/bonsai/bonsai/bim/module/model/preview_base.py @@ -60,8 +60,12 @@ def get_preview_props(context: bpy.types.Context, attr: str): Returns ``None`` if the umbrella isn't attached yet — true briefly during addon register and during plug-out, so polls / draw callbacks must defend against ``None`` rather than assuming the prop is always - available.""" - preview = getattr(context.scene, "BIMPreviewProperties", None) + available. Also tolerates contexts without a ``scene`` attribute + (test mocks built from ``SimpleNamespace``).""" + scene = getattr(context, "scene", None) + if scene is None: + return None + preview = getattr(scene, "BIMPreviewProperties", None) return getattr(preview, attr, None) if preview is not None else None @@ -74,6 +78,17 @@ def is_preview_active(context: bpy.types.Context, attr: str) -> bool: return bool(props is not None and props.is_active) +def any_preview_active(context: bpy.types.Context) -> bool: + """``True`` if any registered preview is currently open. Sister gizmo + polls call this to hide themselves uniformly during ANY preview, so a + new preview registered in ``PREVIEW_CANCEL_OPS`` automatically gates + every parametric gizmo without each one growing a specific check.""" + for attr, _op_name in PREVIEW_CANCEL_OPS: + if is_preview_active(context, attr): + return True + return False + + # --- Lazy closure factories -------------------------------------------------- # # Used by preview gizmo groups when wiring ``BIM_GT_gizmo_dimension``'s diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index 77f4b8a9f2..633715bef6 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -1902,3 +1902,65 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup): geometry_source: Literal["GEONODES", "IFCSVERCHOK"] geo_nodes: Union[bpy.types.GeometryNodeTree, None] sverchok_nodes: Union[sverchok.node_tree.SverchCustomTree, None] + + +class BIMWallFilletPreviewProperties(PropertyGroup): + """Scene-level pending state for the wall-fillet preview flow. + + Scene-level because the fillet spans two walls and commits a third + (corner) wall between them. ``SKIP_SAVE`` fields throughout.""" + + is_active: bpy.props.BoolProperty( + default=False, + options={"SKIP_SAVE"}, + description="True while the wall-fillet preview flow is active.", + ) + wall_a_id: bpy.props.IntProperty( + default=0, + options={"SKIP_SAVE"}, + description=( + "IFC element id of the active wall — the corner wall inherits its " + "material layer set, height, x_angle, and type." + ), + ) + wall_b_id: bpy.props.IntProperty( + default=0, + options={"SKIP_SAVE"}, + description="IFC element id of the other selected wall.", + ) + radius: bpy.props.FloatProperty( + name="Radius", + default=0.5, + soft_min=-10.0, + soft_max=10.0, + subtype="DISTANCE", + unit="LENGTH", + options={"SKIP_SAVE"}, + description="Radius of the circular arc connecting the two walls.", + ) + editing_corner_id: bpy.props.IntProperty( + default=0, + options={"SKIP_SAVE"}, + description=( + "IFC element id of an existing fillet corner being re-edited " + "(non-zero only on the pen-icon re-edit flow). The create " + "operator deletes this corner + its connections before recreating " + "with the new radius." + ), + ) + + if TYPE_CHECKING: + is_active: bool + wall_a_id: int + wall_b_id: int + radius: float + editing_corner_id: int + + +class BIMPreviewProperties(PropertyGroup): + """Umbrella for parametric-edit preview drafts attached to ``Scene``.""" + + wall_fillet: bpy.props.PointerProperty(type=BIMWallFilletPreviewProperties) + + if TYPE_CHECKING: + wall_fillet: BIMWallFilletPreviewProperties diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index 0834263552..ef765ba53c 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -430,7 +430,7 @@ class CycleStairType(bpy.types.Operator, gizmo.CycleTypeMixin): bl_label = "Cycle Stair Type" bl_options = {"REGISTER", "UNDO"} - props_getter = "get_stair_props" + props_getter = tool.Model.get_stair_props type_literal = tool.Model.StairType type_attr = "stair_type" skip_element_check = True @@ -580,7 +580,7 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): ] # Metadata-driven dispatch for props and preferences - props_getter = "get_stair_props" + props_getter = tool.Model.get_stair_props gizmo_pref_name = "stair" @classmethod @@ -593,14 +593,12 @@ class GizmoStairEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): "VIEW3D_GT_lock", self.COLOR_BLUE, "bim.toggle_stair_property", - prop_path="BIMStairProperties.total_length_lock", property_name="total_length_lock", ) self.tread_lock_gizmo = self.create_icon_gizmo( "VIEW3D_GT_lock", (1.0, 1.0, 1.0), "bim.toggle_stair_property", - prop_path="BIMStairProperties.custom_tread_lock", property_name="custom_tread_lock", ) self.plus_gizmo = self.create_icon_gizmo( diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 0bedb86fc6..a0658574ff 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -22,6 +22,7 @@ import copy import math +from collections.abc import Iterable from math import atan2, cos, degrees, pi, sin from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional, Union, get_args @@ -53,6 +54,7 @@ import bonsai.tool as tool from bonsai.bim.ifc import IfcStore from bonsai.bim.module.drawing import gizmos as gizmo from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig +from bonsai.bim.module.model import preview_base from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator from bonsai.bim.module.model.polyline import PolylineOperator @@ -60,6 +62,24 @@ if TYPE_CHECKING: from bonsai.bim.module.model.prop import BIMWallProperties +_FILLET_DEFAULT_RADIUS_M = 0.5 # Fallback when the leg-fraction heuristic cannot resolve a value. +_FILLET_DEFAULT_LEG_FRACTION = 0.25 # Quarter of the shorter available leg — visible without overrunning either wall. +_FILLET_MIN_RADIUS_M = 0.001 # Lower bound — anything smaller renders as a single pixel at common viewport scales. + + +def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool: + """Common pre-flight gate every wall gizmo group's ``poll`` runs first: + viewport gizmos are enabled AND no preview is active. Centralises the + two checks every wall gizmo group otherwise duplicates inline; returning + ``False`` here short-circuits the caller's poll before any per-feature + selection inspection runs.""" + if not tool.Blender.are_viewport_gizmos_enabled(): + return False + if preview_base.any_preview_active(context): + return False + return True + + def regenerate_wall_mesh_from_props(obj: bpy.types.Object) -> None: """Rebuild ``obj.data`` as a preview box from ``BIMWallProperties`` without touching IFC. @@ -122,33 +142,11 @@ def _restore_wall_mesh_if_dirty(obj: bpy.types.Object) -> None: props.mesh_dirty = False -def _validate_wall_for_parametric_edit(obj: bpy.types.Object) -> str | None: - """Return ``None`` if the wall is parametrically editable, else a user-facing reason - string explaining what's missing. Reports the *specific* gap rather than a generic - 'not parametric' so the user knows whether to fix the material layer set, swap the - body representation, or pick a different object.""" - element = tool.Ifc.get_entity(obj) - if not element: - return "Object is not an IFC element." - if not element.is_a("IfcWall"): - return f"Object is an {element.is_a()}, not an IfcWall." - if tool.Model.get_usage_type(element) != "LAYER2": - return "Wall has no IfcMaterialLayerSetUsage with LayerSetDirection AXIS2 (required for parametric editing)." - representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") - if not representation: - return "Wall has no Model/Body/MODEL_VIEW representation to drive parametric dimensions." - if not tool.Model.get_extrusion(representation): - return ( - "Wall body is not an IfcExtrudedAreaSolid " "(e.g. a brep mesh or boolean result without a base extrusion)." - ) - return None - - def _read_wall_state_into_props(obj: bpy.types.Object, props: "BIMWallProperties") -> None: """Populate the draft props from current IFC state. Caller must have validated the - wall via ``_validate_wall_for_parametric_edit`` first — this function assumes the + wall via ``tool.Wall.validate_for_parametric_edit`` first — this function assumes the wall has a LAYER2 usage and an extruded MODEL_VIEW body.""" - geom = _read_wall_geometry(obj) + geom = tool.Wall.read_geometry(obj) assert geom props.anchor_x = geom["anchor_x"] @@ -167,6 +165,30 @@ def _read_wall_state_into_props(obj: bpy.types.Object, props: "BIMWallProperties props.snap_offset_baseline = props.desired_offset_baseline +def _maybe_resync_wall_props_from_ifc(obj: "bpy.types.Object | None") -> None: + """Re-prime ``BIMWallProperties`` from current IFC after an IFC mutation, so + non-edit-mode gizmos read post-mutation coordinates. Must be called from an + operator's ``_execute`` — ID writes from ``GizmoGroup.refresh`` raise + ``AttributeError: Writing to ID classes in this context is not allowed``. + No-op during a draft session; the draft is then the source of truth.""" + if obj is None: + return + if tool.Wall.validate_for_parametric_edit(obj) is not None: + return + props = tool.Model.get_wall_props(obj) + if props.is_editing: + return + _read_wall_state_into_props(obj, props) + + +def _resync_walls_after_mutation(objs: Iterable["bpy.types.Object | None"]) -> None: + """Re-prime each wall's draft props after a one-shot IFC mutation. Safe to + call from operator ``_execute``: ID writes are allowed there, unlike gizmo + refresh.""" + for obj in objs: + _maybe_resync_wall_props_from_ifc(obj) + + class UnjoinWalls(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unjoin_walls" bl_label = "Unjoin Walls" @@ -183,6 +205,81 @@ class UnjoinWalls(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): _commit_pending_wall_edits_for_selection(context) core.unjoin_walls(tool.Ifc, tool.Blender, tool.Geometry, DumbWallJoiner(), tool.Model) + _resync_walls_after_mutation(tool.Blender.get_selected_objects()) + + +class UnjoinWallPathConnection(bpy.types.Operator, tool.Ifc.Operator): + """Surgical counterpart to `UnjoinWalls`: disconnect the active wall from one + specific partner wall, leaving the active wall's other connections intact. The + partner is identified by IFC GlobalId — invariant under Blender-object renames, + file save/reload, and the undo stack — set on the operator properties by the + single-wall unjoin gizmo at click time.""" + + bl_idname = "bim.unjoin_wall_path_connection" + bl_label = "Unjoin Wall Connection" + bl_description = "Disconnect the active wall from a single specific partner wall" + bl_options = {"REGISTER", "UNDO"} + + other_wall_guid: bpy.props.StringProperty(name="Other Wall GlobalId") + + @classmethod + def poll(cls, context): + if not tool.Model.has_selected_ifc_objects(): + cls.poll_message_set("No IFC objects selected.") + return False + return True + + def _execute(self, context): + _commit_pending_wall_edits_for_selection(context) + active = tool.Blender.get_active_object(is_selected=True) + if not active: + self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.") + return + elem_active = tool.Ifc.get_entity(active) + if not elem_active: + self.report({"ERROR"}, "Active object is not bound to an IFC entity.") + return + elem_other = None + if self.other_wall_guid: + try: + elem_other = tool.Ifc.get().by_guid(self.other_wall_guid) + except RuntimeError: + elem_other = None + other = tool.Ifc.get_object(elem_other) if elem_other else None + if not elem_other or not other: + self.report({"ERROR"}, "Could not resolve walls for surgical unjoin.") + return + # Walk the inverse graph for the specific IfcRelConnectsPathElements joining + # these two walls and remove only that one. `disconnect_path`'s + # (relating, related) mode only inspects `relating.ConnectedTo`, so a single + # call misses the rel when it was authored with the opposite orientation. + rels = [ + rel + for rel in getattr(elem_active, "ConnectedTo", []) + if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedElement == elem_other + ] + [ + rel + for rel in getattr(elem_active, "ConnectedFrom", []) + if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingElement == elem_other + ] + for rel in rels: + bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) + # Recreate body+axis on both walls so the mesh state matches the IFC mutation + # and stale miter cuts are dropped. If recreate_wall raises, the rel removal + # has already been committed to the operator's IFC transaction — surface the + # partial-state diagnostic, then re-raise so the exception lands in Blender's + # normal operator error flow. + try: + tool.Model.recreate_wall(elem_active, active) + tool.Model.recreate_wall(elem_other, other) + except Exception: + self.report( + {"ERROR"}, + "Mesh rebuild failed after unjoin. IFC connection was removed but wall " + "meshes may be stale — press Ctrl+Z to undo and restore the previous state.", + ) + raise + _resync_walls_after_mutation([active, other]) class ExtendWallsToUnderside(bpy.types.Operator, tool.Ifc.Operator): @@ -212,6 +309,7 @@ class ExtendWallsToUnderside(bpy.types.Operator, tool.Ifc.Operator): walls.append(obj) if slab and walls: core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slab, walls) + _resync_walls_after_mutation(walls) else: self.report({"ERROR"}, "Please select at least one LAYER2 element and an active element") @@ -253,6 +351,7 @@ class ExtendWallsToWall(bpy.types.Operator, tool.Ifc.Operator): ) tool.Model.recreate_wall(element, obj) tool.Model.recreate_wall(target_element, target_obj) + _resync_walls_after_mutation([target_obj, *objs]) else: self.report({"ERROR"}, "Please select at least one LAYER2 element and one active LAYER2 element") @@ -455,6 +554,7 @@ class SplitWall(bpy.types.Operator, tool.Ifc.Operator): selected_objs = tool.Model.get_selected_mesh_objects() for obj in selected_objs: DumbWallJoiner().split(obj, context.scene.cursor.location) + _resync_walls_after_mutation(selected_objs) return {"FINISHED"} @@ -483,7 +583,11 @@ class MergeWall(bpy.types.Operator, tool.Ifc.Operator): active_obj = context.active_object assert active_obj selected_objs = tool.Model.get_selected_mesh_objects() - DumbWallJoiner().merge(next(o for o in selected_objs if o != active_obj), active_obj) + # The merge deletes the second argument when the walls are collinear; + # only the first survives, so the resync targets the non-active wall. + surviving_obj = next(o for o in selected_objs if o != active_obj) + DumbWallJoiner().merge(surviving_obj, active_obj) + _maybe_resync_wall_props_from_ifc(surviving_obj) return {"FINISHED"} @@ -1624,7 +1728,7 @@ class EnableEditingWall(bpy.types.Operator, tool.Ifc.Operator): obj = context.active_object if not obj: return {"CANCELLED"} - reason = _validate_wall_for_parametric_edit(obj) + reason = tool.Wall.validate_for_parametric_edit(obj) if reason: self.report({"WARNING"}, f"Cannot edit wall parametrically: {reason}") return {"CANCELLED"} @@ -1829,7 +1933,7 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): ), ] - props_getter = "get_wall_props" + props_getter = tool.Model.get_wall_props gizmo_pref_name = "wall" @classmethod @@ -2026,6 +2130,7 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): gz.hide = self.is_gizmo_hidden_by_modal(gz) world_pos = mw @ Vector((cursor_local.x, 0.0, local_z)) gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot) + _apply_wall_extend_flips(gz, self, world_pos, mw, cursor_local, props, billboard_rot) def _update_icon_row_extras(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None: """Position the wall-specific icons in the icon row. @@ -2088,6 +2193,32 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): self.toggle_openings_gizmo.hide = True +def _apply_wall_extend_flips( + gz: bpy.types.Gizmo, + group: "GizmoWallEdition", + world_pos: Vector, + mw: Matrix, + cursor_local: Vector, + props: "BIMWallProperties", + billboard_rot: Matrix, +) -> None: + """Mirror the wall's extend arrows so each points toward the end the click will move. + + Extend-X: arrow points away from the wall endpoint that the operator would + keep fixed, accounting for the camera's screen-X orientation. Extend-Z: + arrow flips downward when the cursor sits below the wall top.""" + if gz is group.extend_x_gizmo: + if props.length > 0 and cursor_local.x > props.anchor_x + props.length / 2: + reference_x = props.anchor_x + else: + reference_x = props.anchor_x + props.length + reference_world = mw @ Vector((reference_x, 0.0, 0.0)) + if gizmo.should_flip_extend_arrow(world_pos, reference_world, billboard_rot): + gz.matrix_basis = gz.matrix_basis @ gizmo.EXTEND_FLIP_MIRROR_X + elif gz is group.extend_z_gizmo and cursor_local.z < props.height - gizmo.EXTEND_FLIP_EPSILON: + gz.matrix_basis = gz.matrix_basis @ gizmo.EXTEND_FLIP_MIRROR_Y + + def _commit_active_wall_edit_if_any(context: bpy.types.Context) -> bpy.types.Object | None: """Return the active object, committing any in-progress wall edit first. @@ -2237,35 +2368,10 @@ class ToggleWallOpenings(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -def _read_wall_geometry(obj: bpy.types.Object) -> dict | None: - """Live-read wall geometry from IFC. Returns ``None`` if the wall is not a LAYER2 extruded wall.""" - element = tool.Ifc.get_entity(obj) - if not element or not tool.Blender.Modifier.is_wall(element): - return None - representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") - if not representation: - return None - extrusion = tool.Model.get_extrusion(representation) - if not extrusion: - return None - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - p1, p2 = ifcopenshell.util.representation.get_reference_line(element) - layer_params = tool.Model.get_material_layer_parameters(element) - x_angle = tool.Model.get_existing_x_angle(extrusion) - return { - "anchor_x": p1[0] * unit_scale, - "length": (p2[0] - p1[0]) * unit_scale, - "height": core.vertical_height_from_extrusion_depth(extrusion.Depth * unit_scale, x_angle), - "x_angle": x_angle, - "thickness": layer_params["thickness"], - "offset": layer_params["offset"], - } - - def _wall_axis_world_segment_from_geom(obj: bpy.types.Object, geom: dict) -> tuple[Vector, Vector]: """Compose the world-space axis segment from an already-read ``geom`` dict. Used by the billboarding gizmo groups so a single cached IFC read drives both - ``_read_wall_geometry`` *and* the segment, avoiding two reads per wall per frame.""" + ``tool.Wall.read_geometry`` *and* the segment, avoiding two reads per wall per frame.""" p1_local = Vector((geom["anchor_x"], 0.0, 0.0)) p2_local = Vector((geom["anchor_x"] + geom["length"], 0.0, 0.0)) return obj.matrix_world @ p1_local, obj.matrix_world @ p2_local @@ -2287,7 +2393,7 @@ class _WallGeomCachedBillboardingMixin(gizmo.BillboardingGizmoGroupMixin): def _get_wall_geom_cached(group: "bpy.types.GizmoGroup", obj: bpy.types.Object) -> dict | None: - """Per-gizmo-group memoised ``_read_wall_geometry``. Without this, a + """Per-gizmo-group memoised ``tool.Wall.read_geometry``. Without this, a billboarding gizmo group re-runs the IFC read on every camera orbit frame — ~120 IFC queries per second per wall, which is unwieldy on dense models. @@ -2309,7 +2415,7 @@ def _get_wall_geom_cached(group: "bpy.types.GizmoGroup", obj: bpy.types.Object) group._wall_geom_cache_gen = current_gen key = obj.name if key not in cache: - cache[key] = _read_wall_geometry(obj) + cache[key] = tool.Wall.read_geometry(obj) return cache[key] @@ -2369,6 +2475,815 @@ def _collinear_boundary_world(seg_a: tuple[Vector, Vector], seg_b: tuple[Vector, ) +def _path_connection_location_world( + seg_self: tuple[Vector, Vector], + self_conn_type: str, + seg_other: tuple[Vector, Vector], + other_conn_type: str, + parallel_threshold: float = 0.9994, +) -> Vector: + """Vector wrapper around `core.compute_path_connection_location`. Used by the + single-wall unjoin gizmo group to place one icon per ``IfcRelConnectsPathElements`` + at its physical join point (an endpoint of the end-connected wall, or the + axis intersection for an ATPATH/ATPATH cross junction).""" + return Vector( + core.compute_path_connection_location( + (tuple(seg_self[0]), tuple(seg_self[1])), + self_conn_type, + (tuple(seg_other[0]), tuple(seg_other[1])), + other_conn_type, + parallel_threshold, + ) + ) + + +def _iter_path_connections( + elem: ifcopenshell.entity_instance, +) -> list[tuple[ifcopenshell.entity_instance, str, str]]: + """For each ``IfcRelConnectsPathElements`` involving ``elem``, yield + ``(other_element, self_connection_type, other_connection_type)``. + + Walks both inverse arrays (``ConnectedTo`` + ``ConnectedFrom``) so the orientation + of each rel is normalised to "self first". Non-wall partners are skipped — a wall + MAY share a path connection with non-wall elements, but the unjoin gizmo only + exposes wall-to-wall joins to match the existing two-wall gizmo's scope.""" + out: list[tuple[ifcopenshell.entity_instance, str, str]] = [] + for rel in getattr(elem, "ConnectedTo", []): + if not rel.is_a("IfcRelConnectsPathElements"): + continue + other = rel.RelatedElement + # `Modifier.is_wall(None)` raises on `None.is_a(...)` — guard before the + # predicate runs. Malformed / partial IFC files can leave a rel's element + # ref unset, and the gizmo loop must survive a stray None rather than + # crashing the per-frame `position_gizmos`. + if other is None or not tool.Blender.Modifier.is_wall(other): + continue + out.append((other, rel.RelatingConnectionType, rel.RelatedConnectionType)) + for rel in getattr(elem, "ConnectedFrom", []): + if not rel.is_a("IfcRelConnectsPathElements"): + continue + other = rel.RelatingElement + if other is None or not tool.Blender.Modifier.is_wall(other): + continue + out.append((other, rel.RelatedConnectionType, rel.RelatingConnectionType)) + return out + + +def _wall_fillet_props(context: bpy.types.Context): + return preview_base.get_preview_props(context, "wall_fillet") + + +def _wall_fillet_preview_active(context: bpy.types.Context) -> bool: + """``True`` while a wall-fillet preview is open.""" + return preview_base.is_preview_active(context, "wall_fillet") + + +_FILLET_SLOPE_TOLERANCE_RAD = 1e-4 + + +def _walls_have_zero_slope_for_fillet(operator: bpy.types.Operator, *walls: bpy.types.Object) -> bool: + """``True`` iff every input wall is vertical (``x_angle`` ~ 0). Reports an + ERROR on the operator and returns ``False`` otherwise. Slanted-extrusion + fillets require swept-along-curve geometry that the banana profile builder + isn't designed for — block the entry points so the user sees a clear + explanation instead of malformed corner geometry.""" + for wall in walls: + if wall is None: + continue + element = tool.Ifc.get_entity(wall) + if element is None: + continue + x_angle = tool.Wall.get_x_angle(element) + if x_angle is None: + continue + if abs(x_angle) > _FILLET_SLOPE_TOLERANCE_RAD: + operator.report( + {"ERROR"}, + "Wall fillet is not supported for slanted walls (non-zero slope). " + "Reset the wall's slope to vertical and try again.", + ) + return False + return True + + +def _build_curved_corner_body_representation( + ifc_file: ifcopenshell.file, + body_context: ifcopenshell.entity_instance, + arc_center_local: tuple[float, float, float], + chord_length_si: float, + radius_si: float, + r_outer_si: float, + r_inner_si: float, + height_si: float, +) -> ifcopenshell.entity_instance: + """Build an ``IfcShapeRepresentation`` with a banana (annular sector) + ``IfcExtrudedAreaSolid``. + + Local frame: origin at ``tangent_a``, +X along the chord to ``tangent_b``, + +Z vertical. ``r_outer_si`` / ``r_inner_si`` come from wall A's + ``IfcMaterialLayerSetUsage`` so the cross-section matches A at + ``tangent_a`` rather than centring on the reference arc.""" + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + + cx_si, cy_si, _ = arc_center_local + dir_a = (-cx_si / radius_si, -cy_si / radius_si) + dir_b = ((chord_length_si - cx_si) / radius_si, -cy_si / radius_si) + + # Tessellate the banana profile as an IfcIndexedPolyCurve of straight + # IfcLineIndex segments rather than analytical trimmed-circle arcs: + # IfcOpenShell's geometry kernel and tool.Model.import_profile's edit-mode + # importer both handle polyline segments unconditionally; trimmed-circle + # alternatives fall through both paths to a coarse fallback or a hard error. + # 24 chord segments per arc is visually smooth and round-trip-stable. + arc_resolution = 24 + cross_z = dir_a[0] * dir_b[1] - dir_a[1] * dir_b[0] + theta_a = math.atan2(dir_a[1], dir_a[0]) + theta_b = math.atan2(dir_b[1], dir_b[0]) + # Take the SHORT angular sweep from theta_a to theta_b. CCW (positive + # signed cross product) means walking in increasing-theta direction. + sweep = theta_b - theta_a + if cross_z >= 0: + if sweep < 0: + sweep += 2 * math.pi + else: + if sweep > 0: + sweep -= 2 * math.pi + + def _arc_points(radius: float) -> list[tuple[float, float]]: + out = [] + for i in range(arc_resolution + 1): + theta = theta_a + sweep * (i / arc_resolution) + out.append((cx_si + radius * math.cos(theta), cy_si + radius * math.sin(theta))) + return out + + # Closed loop in counter-clockwise order: outer arc, radial step to inner + # arc, inner arc walked backwards, radial step back to outer start. The + # outer-to-inner and inner-to-outer steps are pure radial lines because + # the arcs share their endpoint angles. + outer_points = _arc_points(r_outer_si) + inner_points_reversed = list(reversed(_arc_points(r_inner_si))) + raw_points = outer_points + inner_points_reversed + points_ifc = [(x / unit_scale, y / unit_scale) for x, y in raw_points] + + point_list = ifc_file.createIfcCartesianPointList2D(points_ifc) + # Indices are 1-based per IFC schema. The curve auto-closes by referencing + # the first point as the next-segment start; the explicit closing segment + # survives writers that don't honour implicit close. + n = len(points_ifc) + segments = [ifc_file.createIfcLineIndex((i + 1, ((i + 1) % n) + 1)) for i in range(n)] + curve = ifc_file.createIfcIndexedPolyCurve(point_list, segments, False) + profile = ifc_file.createIfcArbitraryClosedProfileDef("AREA", None, curve) + + extrusion = ifc_file.createIfcExtrudedAreaSolid( + profile, + ifc_file.createIfcAxis2Placement3D( + ifc_file.createIfcCartesianPoint((0.0, 0.0, 0.0)), + ifc_file.createIfcDirection((0.0, 0.0, 1.0)), + ifc_file.createIfcDirection((1.0, 0.0, 0.0)), + ), + ifc_file.createIfcDirection((0.0, 0.0, 1.0)), + height_si / unit_scale, + ) + return ifc_file.createIfcShapeRepresentation( + body_context, body_context.ContextIdentifier, "SweptSolid", [extrusion] + ) + + +def _apply_fillet_corner_geometry( + ifc_file: ifcopenshell.file, + corner_obj: bpy.types.Object, + geom: dict, + wall_a_obj: bpy.types.Object, +) -> tuple[Vector, Vector, Vector, float] | None: + """Position the corner wall at ``tangent_a`` and rebuild its banana body + from ``geom``. Shared by the creation and regenerate paths so a + neighbour-driven recalc matches creation-time output even when wall A's + layer set has been edited since. + + Returns ``(x_dir, y_dir, z_dir, chord_length_si)`` on success or ``None`` + on degenerate chord / missing Body context. All probes run before any + mutation, so failures leave the corner wall untouched.""" + tangent_a = Vector(geom["tangent_a"]) + tangent_b = Vector(geom["tangent_b"]) + chord = tangent_b - tangent_a + chord_length_si = chord.length + if chord_length_si < 1e-6: + return None + body_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + if body_context is None: + return None + + x_dir = chord.normalized() + z_dir = Vector((0.0, 0.0, 1.0)) + y_dir = z_dir.cross(x_dir).normalized() + corner_obj.matrix_world = Matrix( + ( + (x_dir.x, y_dir.x, z_dir.x, tangent_a.x), + (x_dir.y, y_dir.y, z_dir.y, tangent_a.y), + (x_dir.z, y_dir.z, z_dir.z, tangent_a.z), + (0.0, 0.0, 0.0, 1.0), + ) + ) + bonsai.core.geometry.edit_object_placement( + tool.Ifc, tool.Geometry, tool.Surveyor, obj=corner_obj, apply_scale=False + ) + + arc_center_world = Vector(geom["arc_center"]) + v_world = arc_center_world - tangent_a + arc_center_local = (v_world.dot(x_dir), v_world.dot(y_dir), v_world.dot(z_dir)) + + # Banana cross-section side: ``side_sign`` picks whether the body endpoints + # extend toward the arc center (s = -1) or away from it (s = +1), so the + # cross-section at tangent_a matches wall A's body span instead of being + # centred on the reference arc. + radial_a_world = tangent_a - arc_center_world + if radial_a_world.length > 1e-6: + radial_a_world = radial_a_world.normalized() + wall_a_y_world = wall_a_obj.matrix_world.col[1].to_3d().normalized() + side_sign = 1.0 if wall_a_y_world.dot(radial_a_world) >= 0.0 else -1.0 + else: + side_sign = -1.0 + + # ``arc_radius`` is signed (negative = inverted fillet); banana radii use + # the magnitude — the sign only flips which side of A's reference line + # the arc center sits on, not the curve radii themselves. + radius_si = abs(geom["arc_radius"]) + offset_si = geom["profile_offset"] or 0.0 + thickness_si = geom["profile_thickness"] + r_endpoint_1 = abs(radius_si + side_sign * offset_si) + r_endpoint_2 = abs(radius_si + side_sign * (offset_si + thickness_si)) + r_outer_si = max(r_endpoint_1, r_endpoint_2) + r_inner_si = min(r_endpoint_1, r_endpoint_2) + + new_body = _build_curved_corner_body_representation( + ifc_file, + body_context, + arc_center_local=arc_center_local, + chord_length_si=chord_length_si, + radius_si=radius_si, + r_outer_si=r_outer_si, + r_inner_si=r_inner_si, + height_si=geom["height"] or 3.0, + ) + tool.Model.replace_object_ifc_representation(body_context, corner_obj, new_body) + return x_dir, y_dir, z_dir, chord_length_si + + +def _resolve_two_walls(context: bpy.types.Context) -> tuple[bpy.types.Object, bpy.types.Object] | None: + """``(active, other)`` from a 2-wall selection, both LAYER2 with straight axes.""" + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + return None + active = context.active_object + if active is None or active not in selected: + return None + other = next((o for o in selected if o is not active), None) + if other is None: + return None + for obj in (active, other): + element = tool.Ifc.get_entity(obj) + if element is None or not element.is_a("IfcWall"): + return None + if not tool.Wall.has_layer2_usage(element): + return None + if not tool.Wall.is_straight_axis(element): + return None + if tool.Parametric.is_fillet_corner_wall(element): + # Re-filleting a curved corner would treat its chord as the + # reference line and produce nonsense geometry. + return None + return active, other + + +def _pick_dominant_wall_material( + element: ifcopenshell.entity_instance, +) -> Optional[ifcopenshell.entity_instance]: + """Return a single ``IfcMaterial`` representative of ``element``'s effective + material — the thickest layer's material when the element resolves to a + layer set / usage, the material itself when it is already plain, or + ``None`` for unsupported set kinds and elements with no material.""" + material = tool.Material.get_material(element, should_inherit=True) + if material is None: + return None + if material.is_a("IfcMaterial"): + return material + layer_set = None + if material.is_a("IfcMaterialLayerSetUsage"): + layer_set = material.ForLayerSet + elif material.is_a("IfcMaterialLayerSet"): + layer_set = material + if layer_set is None: + return None + layers_with_material = [layer for layer in (layer_set.MaterialLayers or ()) if layer.Material is not None] + if not layers_with_material: + return None + thickest = max(layers_with_material, key=lambda layer: layer.LayerThickness or 0.0) + return thickest.Material + + +def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: + """Rebuild a fillet corner wall's banana body from ``BBIM_Wall.FilletRadius`` + and its neighbours' current layer parameters.""" + ifc_file = tool.Ifc.get() + if ifc_file is None: + return + radius_si = ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "FilletRadius") + if not radius_si: + return + + # Find the two neighbor walls from IfcRelConnectsPathElements. The corner- + # side connection type is NOTDEFINED so neighbours don't miter against the + # chord-axis reference line — take the single rel on each side of the + # corner's inverse graph rather than filtering on type. + wall_a = None + for rel in getattr(element, "ConnectedFrom", []): + if rel.is_a("IfcRelConnectsPathElements"): + wall_a = rel.RelatingElement + break + wall_b = None + for rel in getattr(element, "ConnectedTo", []): + if rel.is_a("IfcRelConnectsPathElements"): + wall_b = rel.RelatedElement + break + if wall_a is None or wall_b is None: + return + wall_a_obj = tool.Ifc.get_object(wall_a) + wall_b_obj = tool.Ifc.get_object(wall_b) + if wall_a_obj is None or wall_b_obj is None: + return + + geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, float(radius_si)) + if geom is None or not geom["valid"]: + return + + # Re-anchors the corner's ObjectPlacement at the new tangent_a and rebuilds + # the banana body. If a neighbour moved, the new placement follows; if + # neither moved, the new matrix equals the old within floating-point noise. + _apply_fillet_corner_geometry(ifc_file, obj, geom, wall_a_obj) + + +class EnableWallFilletPreview(bpy.types.Operator): + """Enter wall-fillet preview mode for two selected walls. No IFC + mutation until finish.""" + + bl_idname = "bim.enable_wall_fillet_preview" + bl_label = "Enter Wall Fillet Preview" + bl_description = "Begin tuning the fillet radius before committing the rounded corner" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + if _resolve_two_walls(context) is None: + cls.poll_message_set("Select exactly 2 LAYER2 walls with straight axes.") + return False + return True + + def execute(self, context): + walls = _resolve_two_walls(context) + if walls is None: + self.report({"ERROR"}, "Selection no longer eligible for fillet preview.") + return {"CANCELLED"} + wall_a, wall_b = walls + + elem_a = tool.Ifc.get_entity(wall_a) + elem_b = tool.Ifc.get_entity(wall_b) + + if not _walls_have_zero_slope_for_fillet(self, wall_a, wall_b): + return {"CANCELLED"} + + # Joined / intersecting only; parallel pairs have no corner to round. + seg_a = tool.Wall.get_world_reference_line(wall_a) + seg_b = tool.Wall.get_world_reference_line(wall_b) + if seg_a is None or seg_b is None: + self.report({"ERROR"}, "Could not read reference line on one of the walls.") + return {"CANCELLED"} + are_joined = _are_walls_joined(elem_a, elem_b) + state, _ = core.classify_wall_join_state( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + are_joined, + core.PARALLEL_DOT_THRESHOLD, + core.COLLINEAR_LINE_TOLERANCE, + ) + if state not in {"intersect", "joined"}: + self.report({"ERROR"}, f"Fillet requires intersecting or joined walls (state was {state}).") + return {"CANCELLED"} + + preview_base.sync_uncommitted_moves([wall_a, wall_b]) + + props = _wall_fillet_props(context) + if props is None: + self.report({"ERROR"}, "Wall fillet preview state is unavailable.") + return {"CANCELLED"} + + # Auto-cancel any prior preview before opening a fresh one — fillet + # creates a new IFC entity at finish. + if props.is_active: + bpy.ops.bim.cancel_wall_fillet_preview() + + # Default radius: a fraction of the shorter available leg, clamped + # against the tangent-overshoot upper bound. + geom = tool.Wall.compute_wall_fillet_geometry(wall_a, wall_b, radius=_FILLET_DEFAULT_RADIUS_M) + default_radius = _FILLET_DEFAULT_RADIUS_M + if geom is not None and geom.get("sweep_angle") and geom["sweep_angle"] > 1e-3: + leg_a_available = geom.get("leg_a_available") or 0.0 + leg_b_available = geom.get("leg_b_available") or 0.0 + shortest_leg = min(leg_a_available, leg_b_available) + if shortest_leg > 1e-6: + upper = shortest_leg / max(math.tan(geom["sweep_angle"] / 2), 1e-6) + default_radius = max( + _FILLET_MIN_RADIUS_M, + min(_FILLET_DEFAULT_LEG_FRACTION * shortest_leg, upper, _FILLET_DEFAULT_RADIUS_M), + ) + + props.wall_a_id = elem_a.id() + props.wall_b_id = elem_b.id() + props.radius = default_radius + props.editing_corner_id = 0 + props.is_active = True + return {"FINISHED"} + + +class FinishWallFilletPreview(bpy.types.Operator): + """Commit the previewed fillet with the tuned radius and exit preview. + + Preview state survives a failed commit so the user can re-tune without + re-selecting.""" + + bl_idname = "bim.finish_wall_fillet_preview" + bl_label = "Apply Wall Fillet" + bl_description = "Commit the rounded corner with the previewed radius" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + if context.screen is None: + return {"CANCELLED"} + props = preview_base.get_preview_props(context, "wall_fillet") + if props is None or not props.is_active: + return {"CANCELLED"} + if tool.Ifc.get() is None: + self.report({"ERROR"}, "No IFC file loaded.") + return {"CANCELLED"} + # bpy.ops promotes ``self.report({"ERROR"}) + return CANCELLED`` from + # the dispatched operator to RuntimeError. Catch it so this operator + # returns cleanly instead of leaving Blender's operator state + # half-broken (which would silently disable downstream gizmo polls). + try: + result = bpy.ops.bim.create_wall_fillet( + wall_a_id=props.wall_a_id, + wall_b_id=props.wall_b_id, + radius=props.radius, + editing_corner_id=props.editing_corner_id, + ) + except RuntimeError as exc: + self.report({"ERROR"}, str(exc)) + return {"CANCELLED"} + if "FINISHED" in result: + props.is_active = False + props.wall_a_id = 0 + props.wall_b_id = 0 + props.editing_corner_id = 0 + return result + + +class CancelWallFilletPreview(bpy.types.Operator): + """Exit wall-fillet preview without committing.""" + + bl_idname = "bim.cancel_wall_fillet_preview" + bl_label = "Cancel Wall Fillet" + bl_description = "Discard the previewed fillet" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + if context.screen is None: + return {"CANCELLED"} + props = preview_base.get_preview_props(context, "wall_fillet") + if props is None or not props.is_active: + return {"CANCELLED"} + props.is_active = False + props.wall_a_id = 0 + props.wall_b_id = 0 + props.editing_corner_id = 0 + return {"FINISHED"} + + +class EnableWallFilletPreviewFromCorner(bpy.types.Operator): + """Re-open the fillet preview on an existing corner wall (pen-icon entry). + + Validate deletes and recreates the corner inside a single undo step.""" + + bl_idname = "bim.enable_wall_fillet_preview_from_corner" + bl_label = "Edit Wall Fillet" + bl_description = "Open the fillet preview for an existing rounded corner — drag radius to retune" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + return False + element = tool.Ifc.get_entity(selected[0]) + return element is not None and tool.Parametric.is_fillet_corner_wall(element) + + def execute(self, context: bpy.types.Context): + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + self.report({"ERROR"}, "Select exactly one fillet corner wall.") + return {"CANCELLED"} + corner_obj = selected[0] + corner_elem = tool.Ifc.get_entity(corner_obj) + if corner_elem is None or not tool.Parametric.is_fillet_corner_wall(corner_elem): + self.report({"ERROR"}, "Selection is not a fillet corner wall.") + return {"CANCELLED"} + + radius = ifcopenshell.util.element.get_pset(corner_elem, "BBIM_Wall", "FilletRadius") + if not radius: + self.report({"ERROR"}, "Corner wall has no FilletRadius pset to re-edit.") + return {"CANCELLED"} + + # The corner's own side of the rel is NOTDEFINED (see regenerate_ + # fillet_corner_wall) so neighbours don't miter against the chord axis + # — read the single rel on each side of the inverse graph rather than + # filtering on connection type. + wall_a = None + for rel in getattr(corner_elem, "ConnectedFrom", []): + if rel.is_a("IfcRelConnectsPathElements"): + wall_a = rel.RelatingElement + break + wall_b = None + for rel in getattr(corner_elem, "ConnectedTo", []): + if rel.is_a("IfcRelConnectsPathElements"): + wall_b = rel.RelatedElement + break + if wall_a is None or wall_b is None: + self.report({"ERROR"}, "Corner wall is not connected to both source walls anymore.") + return {"CANCELLED"} + + wall_a_obj = tool.Ifc.get_object(wall_a) + wall_b_obj = tool.Ifc.get_object(wall_b) + if not _walls_have_zero_slope_for_fillet(self, wall_a_obj, wall_b_obj): + return {"CANCELLED"} + + props = _wall_fillet_props(context) + if props is None: + self.report({"ERROR"}, "Wall fillet preview state is unavailable.") + return {"CANCELLED"} + if props.is_active: + bpy.ops.bim.cancel_wall_fillet_preview() + + props.wall_a_id = wall_a.id() + props.wall_b_id = wall_b.id() + props.radius = float(radius) + props.editing_corner_id = corner_elem.id() + props.is_active = True + return {"FINISHED"} + + +class CreateWallFillet(bpy.types.Operator, tool.Ifc.Operator): + """Replace the corner between two straight walls with a curved LAYER2 + corner wall (banana body, inherits layer set / height / x_angle / type + from wall A).""" + + bl_idname = "bim.create_wall_fillet" + bl_label = "Create Wall Fillet" + bl_description = "Replace the corner between two walls with a rounded corner of the given radius" + bl_options = {"REGISTER", "UNDO"} + + wall_a_id: bpy.props.IntProperty(name="Wall A (active) IFC id") + wall_b_id: bpy.props.IntProperty(name="Wall B (other) IFC id") + radius: bpy.props.FloatProperty( + name="Radius", + default=0.5, + subtype="DISTANCE", + unit="LENGTH", + description=( + "Signed radius — positive produces a convex outward fillet, " + "negative flips the arc center to the opposite side for an " + "inverted (concave inward) corner." + ), + ) + editing_corner_id: bpy.props.IntProperty( + name="Existing fillet corner IFC id", + default=0, + description=( + "Non-zero on the pen-icon re-edit flow. The operator deletes this " + "corner + its path connections before recreating with the new radius." + ), + ) + + if TYPE_CHECKING: + wall_a_id: int + wall_b_id: int + radius: float + editing_corner_id: int + + def _execute(self, context): + ifc_file = tool.Ifc.get() + if ifc_file is None: + self.report({"ERROR"}, "No IFC file loaded.") + return {"CANCELLED"} + + try: + elem_a = ifc_file.by_id(self.wall_a_id) + elem_b = ifc_file.by_id(self.wall_b_id) + except Exception: + self.report({"ERROR"}, "One of the source walls is no longer in the IFC file.") + return {"CANCELLED"} + + wall_a_obj = tool.Ifc.get_object(elem_a) + wall_b_obj = tool.Ifc.get_object(elem_b) + if wall_a_obj is None or wall_b_obj is None: + self.report({"ERROR"}, "One of the source walls has no Blender object.") + return {"CANCELLED"} + + if not _walls_have_zero_slope_for_fillet(self, wall_a_obj, wall_b_obj): + return {"CANCELLED"} + + geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, self.radius) + if geom is None or not geom["valid"]: + reason = geom.get("reason") if geom else "unknown" + self.report({"ERROR"}, f"Fillet geometry rejected (reason: {reason}).") + return {"CANCELLED"} + if geom["wall_type_id"] is None: + self.report({"ERROR"}, "Active wall has no IfcWallType to inherit.") + return {"CANCELLED"} + + tangent_a = Vector(geom["tangent_a"]) + tangent_b = Vector(geom["tangent_b"]) + side_a = geom["wall_a_join_side"] + side_b = geom["wall_b_join_side"] + chord = tangent_b - tangent_a + chord_length = chord.length + if chord_length < 1e-6: + self.report({"ERROR"}, "Tangent points coincide — invalid fillet geometry.") + return {"CANCELLED"} + + # Pen-icon re-edit path: remove the existing fillet corner + its two + # path connections to A and B before recreating. The deletion + + # recreation runs in the same tool.Ifc.Operator transaction, so a + # single undo restores the pre-re-edit state. + if self.editing_corner_id: + try: + old_corner = ifc_file.by_id(self.editing_corner_id) + except Exception: + old_corner = None + if old_corner is not None: + for rel in list(getattr(old_corner, "ConnectedFrom", [])) + list( + getattr(old_corner, "ConnectedTo", []) + ): + if rel.is_a("IfcRelConnectsPathElements"): + bonsai.core.geometry.remove_connection(tool.Geometry, connection=rel) + old_corner_obj = tool.Ifc.get_object(old_corner) + ifcopenshell.api.root.remove_product(ifc_file, product=old_corner) + if old_corner_obj is not None: + bpy.data.objects.remove(old_corner_obj) + + # Drop any existing direct connection between A and B before + # retopologising — the corner wall will own the new connections at + # both ends. + for conn in list(elem_a.ConnectedTo) + list(elem_a.ConnectedFrom): + if not conn.is_a("IfcRelConnectsPathElements"): + continue + other = conn.RelatedElement if conn.RelatingElement == elem_a else conn.RelatingElement + if other == elem_b: + bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn) + + # Shorten A and B so their corner-side endpoints sit on the tangent + # points. DumbWallJoiner.extend projects the world-space target onto + # the wall's local axis and rewrites the relevant endpoint, then + # regenerates the body so it matches the new axis. + joiner = DumbWallJoiner() + joiner.extend(wall_a_obj, tangent_a, connection=side_a) + joiner.extend(wall_b_obj, tangent_b, connection=side_b) + + # Instantiate the corner wall from A's wall type so it inherits the + # material layer set, height, x_angle, and IfcWallType. + bpy.ops.bim.add_occurrence(relating_type_id=geom["wall_type_id"]) + corner_obj = bpy.context.active_object + if corner_obj is None: + self.report({"ERROR"}, "Failed to instantiate the corner wall.") + return {"CANCELLED"} + corner_elem = tool.Ifc.get_entity(corner_obj) + if corner_elem is None: + self.report({"ERROR"}, "Corner wall has no IFC entity after creation.") + return {"CANCELLED"} + + # IfcMaterialLayerSetUsage on a wall contracts that the body is + # derived from the Axis swept along the layer-set thicknesses; + # spec-honouring importers discard an explicit body when they see a + # usage. The corner's defining geometry IS the explicit banana body, + # so neither the usage form nor the owning IfcWallType may stay + # associated. A plain IfcMaterial carries no swept-layer contract — + # the corner inherits a single material from the dominant (thickest) + # layer of wall A's effective material set for QTO / colour / + # reporting purposes without putting the explicit body at risk. + ifcopenshell.api.material.unassign_material(ifc_file, products=[corner_elem]) + ifcopenshell.api.type.unassign_type(ifc_file, related_objects=[corner_elem]) + + dominant_material = _pick_dominant_wall_material(elem_a) + if dominant_material is not None: + ifcopenshell.api.material.assign_material( + ifc_file, + products=[corner_elem], + type="IfcMaterial", + material=dominant_material, + ) + + placement = _apply_fillet_corner_geometry(ifc_file, corner_obj, geom, wall_a_obj) + if placement is None: + self.report({"ERROR"}, "Could not apply fillet corner geometry (degenerate chord or missing body context).") + return {"CANCELLED"} + _, _, _, chord_length_si = placement + + # Axis: 2-point straight chord polyline from (0,0) to (chord_length,0) + # in wall-local IFC units. The body curves while the axis stays + # straight — IFC viewers and downstream Bonsai code that read the + # reference line via get_reference_line get a usable 2-point result + # instead of partial samples off a 3-point arc. + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + joiner.set_axis( + corner_elem, + Vector((0.0, 0.0)), + Vector((chord_length_si / unit_scale, 0.0)), + ) + + # Mark the corner wall BEFORE the downstream recalculate so + # tool.Model.recreate_wall short-circuits and preserves the curved + # geometry. The pset also gates the enable poll. FilletRadius is + # stored alongside IsFilletCorner so the corner can be rebuilt later + # (neighbour move, layer-thickness edit, pen-icon re-edit). + pset = ifcopenshell.api.pset.add_pset(ifc_file, product=corner_elem, name="BBIM_Wall") + ifcopenshell.api.pset.edit_pset( + ifc_file, + pset=pset, + properties={"IsFilletCorner": True, "FilletRadius": float(self.radius)}, + ) + + # Connect A and B to the corner with the corner's OWN side typed as + # NOTDEFINED rather than ATSTART/ATEND. regenerate_wall_representation + # .join() early-returns when either side is NOTDEFINED, so neighbour + # A's miter cut never reads the corner's chord-axis reference line. + # A and B end FLAT at tangent_a / tangent_b — which is perpendicular + # to their own axis AND to the curve's tangent direction at that + # point, so the neighbour cross-sections align exactly with the + # banana profile's cap. + ifcopenshell.api.geometry.connect_path( + ifc_file, + relating_element=elem_a, + related_element=corner_elem, + relating_connection=side_a, + related_connection="NOTDEFINED", + ) + ifcopenshell.api.geometry.connect_path( + ifc_file, + relating_element=corner_elem, + related_element=elem_b, + relating_connection="NOTDEFINED", + related_connection=side_b, + ) + + # Recalculate A and B so their miter cuts pick up the new connections + # to the corner. The corner itself is skipped by tool.Model. + # recreate_wall's IsFilletCorner gate, preserving the curved body. + tool.Model.recalculate_walls([wall_a_obj, corner_obj, wall_b_obj]) + _resync_walls_after_mutation([wall_a_obj, corner_obj, wall_b_obj]) + return {"FINISHED"} + + +def _wall_fillet_gizmo_x_matrix(location: Vector, x_direction: Vector) -> Matrix: + """4×4 matrix placing a gizmo at ``location`` with local +X aligned to + ``x_direction`` in world space.""" + x = x_direction.normalized() + seed = Vector((0, 0, 1)) if abs(x.z) < 0.9 else Vector((1, 0, 0)) + y = (seed - x * seed.dot(x)).normalized() + z = x.cross(y) + mat = Matrix.Identity(4) + mat[0][:3] = (x.x, y.x, z.x) + mat[1][:3] = (x.y, y.y, z.y) + mat[2][:3] = (x.z, y.z, z.z) + mat.translation = location + return mat + + +def _wall_fillet_preview_walls(context: bpy.types.Context): + """``(wall_a_obj, wall_b_obj)`` pinned by the preview, or ``(None, None)`` + when inactive or stale.""" + props = _wall_fillet_props(context) + if props is None or not props.is_active: + return None, None + ifc_file = tool.Ifc.get() + if ifc_file is None: + return None, None + try: + elem_a = ifc_file.by_id(props.wall_a_id) + elem_b = ifc_file.by_id(props.wall_b_id) + except (RuntimeError, KeyError): + return None, None + wall_a_obj = tool.Ifc.get_object(elem_a) if elem_a else None + wall_b_obj = tool.Ifc.get_object(elem_b) if elem_b else None + return wall_a_obj, wall_b_obj + + class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): """Activates when a wall (active) and one non-wall blender object are co-selected. @@ -2387,8 +3302,7 @@ class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin @classmethod def poll(cls, context: bpy.types.Context) -> bool: - prefs = tool.Blender.get_addon_preferences() - if not prefs.gizmos.draw_gizmos_in_3d_viewport: + if not _wall_gizmo_poll_gate(context): return False selected = tool.Blender.get_selected_objects() if len(selected) != 2: @@ -2456,8 +3370,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin @classmethod def poll(cls, context: bpy.types.Context) -> bool: - prefs = tool.Blender.get_addon_preferences() - if not prefs.gizmos.draw_gizmos_in_3d_viewport: + if not _wall_gizmo_poll_gate(context): return False selected = tool.Blender.get_selected_objects() if len(selected) != 2: @@ -2533,19 +3446,12 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin # Hide the gizmo when walls are nearly parallel (intersection would be unreasonably far). # cos(2°) ≈ 0.9994 → walls within ~2° of parallel are treated as parallel for this purpose. PARALLEL_DOT_THRESHOLD = 0.9994 - # The intersection must be within this many *wall-lengths* of the NEAREST endpoint - # of each wall. This filters out the case where two walls are offset from world - # origin and their extrapolated axes happen to cross at a point that isn't near - # either wall's actual endpoints (which previously caused the icon to land at - # world origin for walls whose axes coincidentally converged there). - MAX_DISTANCE_TO_ENDPOINT_FACTOR = 0.75 # Perpendicular tolerance (m) for treating two parallel wall axes as collinear. COLLINEAR_LINE_TOLERANCE = 0.05 @classmethod def poll(cls, context: bpy.types.Context) -> bool: - prefs = tool.Blender.get_addon_preferences() - if not prefs.gizmos.draw_gizmos_in_3d_viewport: + if not _wall_gizmo_poll_gate(context): return False selected = tool.Blender.get_selected_objects() if len(selected) != 2: @@ -2556,6 +3462,11 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin return False return True + # Screen-space vertical offset between stacked icons in a state branch — + # camera's screen-up so the fillet icon sits visibly clear of the + # join/unjoin icon at any view angle. + ICON_STACK_OFFSET_Y: ClassVar[float] = 0.4 + def setup(self, context: bpy.types.Context) -> None: prefs = tool.Blender.get_addon_preferences() default_color = prefs.decorations_colour[:3] @@ -2568,9 +3479,15 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin self.extend_to_wall_icon = self.setup_icon_gizmo( "VIEW3D_GT_extend", default_color, highlight_color, "bim.extend_walls_to_wall" ) + # Fillet entry — shows in the same two states (joined / intersect) + # where rounding the corner is well-defined. Click enters the preview + # flow; GizmoWallFilletPreview takes over from there. + self.fillet_icon = self.setup_icon_gizmo( + "VIEW3D_GT_fillet", default_color, highlight_color, "bim.enable_wall_fillet_preview" + ) def _all_icons(self) -> tuple[bpy.types.Gizmo, ...]: - return (self.unjoin_icon, self.merge_icon, self.join_icon, self.extend_to_wall_icon) + return (self.unjoin_icon, self.merge_icon, self.join_icon, self.extend_to_wall_icon, self.fillet_icon) def _hide_all(self) -> None: for icon in self._all_icons(): @@ -2602,6 +3519,12 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin self.merge_icon.hide = True self.join_icon.hide = True self.extend_to_wall_icon.hide = True + # Fillet entry stacked above the unjoin icon in screen-up. + screen_up = gizmo.get_screen_up(billboard_rot) + self.fillet_icon.matrix_basis = gizmo.billboarded_at( + corner + screen_up * self.ICON_STACK_OFFSET_Y, billboard_rot + ) + self.fillet_icon.hide = False return # State 2: walls are collinear (parallel axes on the same line) → show Merge @@ -2613,10 +3536,16 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin self.unjoin_icon.hide = True self.join_icon.hide = True self.extend_to_wall_icon.hide = True + self.fillet_icon.hide = True return - # State 3: non-parallel walls whose axes meet near each wall's endpoint - # → show Join at the floor + Extend-to-Wall at the active wall's top. + # State 3: non-parallel walls → show Join at the floor + Extend-to-Wall + # at the active wall's top. PARALLEL_DOT_THRESHOLD (cos 2°) is the only + # bound that matters: walls within 2° of parallel produce extrusion + # joints that race toward infinity, so project_axis_intersection + # returns None and hits the early-return below. Beyond that, any + # crossing is geometrically valid — distance from the nearest endpoint + # is the user's concern, not ours. intersection_tuple = core.project_axis_intersection( (tuple(seg_a[0]), tuple(seg_a[1])), (tuple(seg_b[0]), tuple(seg_b[1])), @@ -2626,16 +3555,6 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin self._hide_all() return intersection = Vector(intersection_tuple) - len_a = (seg_a[1] - seg_a[0]).length - len_b = (seg_b[1] - seg_b[0]).length - near_a = min((intersection - seg_a[0]).length, (intersection - seg_a[1]).length) - near_b = min((intersection - seg_b[0]).length, (intersection - seg_b[1]).length) - if ( - near_a > len_a * self.MAX_DISTANCE_TO_ENDPOINT_FACTOR - or near_b > len_b * self.MAX_DISTANCE_TO_ENDPOINT_FACTOR - ): - self._hide_all() - return # Join sits on the floor (lowest endpoint Z across both wall axes), exactly # where the corner meets the ground — no visibility lift. @@ -2647,7 +3566,7 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin # Extend-to-Wall sits at the active wall's top, same XY as the join icon — # the Z gap is what differentiates "join at corner" from "extend into other". active = context.active_object if context.active_object in selected else None - geom = _read_wall_geometry(active) if active else None + geom = tool.Wall.read_geometry(active) if active else None if geom is None: self.extend_to_wall_icon.hide = True else: @@ -2656,10 +3575,462 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin self.extend_to_wall_icon.matrix_basis = gizmo.billboarded_at(extend_world, billboard_rot) self.extend_to_wall_icon.hide = False + # Fillet entry stacked above the join icon in screen-up. + screen_up = gizmo.get_screen_up(billboard_rot) + self.fillet_icon.matrix_basis = gizmo.billboarded_at( + join_world + screen_up * self.ICON_STACK_OFFSET_Y, billboard_rot + ) + self.fillet_icon.hide = False + self.unjoin_icon.hide = True self.merge_icon.hide = True +class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): + """Activates when exactly one LAYER2 wall is selected. Surfaces an unjoin icon at + every join location inferred from the wall's IfcRelConnectsPathElements inverse + graph — the single-selection mirror of `GizmoWallJoinIntersection`'s two-wall + unjoin state. A wall may participate in many such rels (up to 1 ATSTART + 1 ATEND + by end, plus unlimited ATPATH T-junctions), so a pool of icons is preallocated + and hidden on a per-frame basis based on the live connection set. + + Each visible icon dispatches `bim.unjoin_wall_path_connection` with the partner + wall's GlobalId set on the bound operator properties, so a click removes only + the single rel under that icon — the other connections on the same wall survive. + + Mutually exclusive with `GizmoWallJoinIntersection` via `poll()` (that group + requires len(selected) == 2; this one requires 1).""" + + bl_idname = "OBJECT_GGT_bim_wall_unjoin_single" + bl_label = "Wall Unjoin (single selection) Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + # Pool size. ATSTART + ATEND + ATPATH connections are rarely more than a handful + # on real models; 16 is generous enough that excess is exceptional. Excess drops + # a one-time console warning. The cap exists because Blender only permits + # GizmoGroup to allocate gizmos inside setup() — draw_prepare / refresh-time + # creation is forbidden — so the pool must be sized upfront for the worst case. + POOL_SIZE = 16 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + if not _wall_gizmo_poll_gate(context): + return False + active = tool.Blender.get_active_object(is_selected=True) + if active is None: + return False + selected = tool.Blender.get_selected_objects() + if len(selected) != 1: + return False + element = tool.Ifc.get_entity(active) + if not element or not tool.Parametric.is_path_connectable_wall(element): + return False + return True + + def setup(self, context: bpy.types.Context) -> None: + prefs = tool.Blender.get_addon_preferences() + default_color = prefs.decorations_colour[:3] + highlight_color = prefs.decorator_color_selected[:3] + # Bind the operator on each pool icon ONCE at setup time and keep the returned + # OperatorProperties handles. target_set_operator allocates a fresh handle on + # every call, so calling it from position_gizmos (which fires every redraw + # frame via draw_prepare) would discard and re-allocate ~60Hz per visible + # icon. Stashing the handles lets per-frame work be a plain property write. + self.unjoin_icons = [] + self.unjoin_op_props = [] + for _ in range(self.POOL_SIZE): + icon = self.setup_icon_gizmo( + "VIEW3D_GT_unjoin", default_color, highlight_color, "bim.unjoin_wall_path_connection" + ) + icon.hide = True + self.unjoin_icons.append(icon) + self.unjoin_op_props.append(icon.target_set_operator("bim.unjoin_wall_path_connection")) + + def position_gizmos(self, context: bpy.types.Context) -> None: + # Default: hide every pool slot. The visible-set is rebuilt from the live + # connection list each frame so disconnects/reconnects elsewhere in the + # session don't leave ghost icons behind. + for icon in self.unjoin_icons: + icon.hide = True + + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + return + wall_obj = selected[0] + elem = tool.Ifc.get_entity(wall_obj) + geom = _get_wall_geom_cached(self, wall_obj) + if elem is None or geom is None: + return + seg_self = _wall_axis_world_segment_from_geom(wall_obj, geom) + billboard_rot = gizmo.get_billboard_rotation(context) + + connections = _iter_path_connections(elem) + if len(connections) > self.POOL_SIZE and not getattr(self, "_pool_cap_warned", False): + print( + f"[bonsai] GizmoWallUnjoinSingle: wall has {len(connections)} path connections; " + f"only the first {self.POOL_SIZE} unjoin gizmos are shown." + ) + self._pool_cap_warned = True + + for slot_idx, (other_elem, self_ct, other_ct) in enumerate(connections): + if slot_idx >= self.POOL_SIZE: + break + other_obj = tool.Ifc.get_object(other_elem) + if other_obj is None: + continue + other_geom = _get_wall_geom_cached(self, other_obj) + if other_geom is None: + continue + seg_other = _wall_axis_world_segment_from_geom(other_obj, other_geom) + location = tool.Wall.path_connection_location_world(seg_self, self_ct, seg_other, other_ct) + icon = self.unjoin_icons[slot_idx] + icon.matrix_basis = gizmo.billboarded_at(location, billboard_rot) + icon.hide = False + # Only the partner-GlobalId property is rewritten per frame; the operator + # binding itself is the long-lived handle set up at setup() time. GlobalId + # (not Blender object name) keeps the binding stable across renames, file + # save/reload, and any sit-in-the-undo-stack interlude between dispatch + # and execute. + self.unjoin_op_props[slot_idx].other_wall_guid = other_elem.GlobalId + + +class GizmoWallFilletPreview(bpy.types.GizmoGroup): + """Gizmo group for the wall-fillet preview: radius dimension widget + + trim-length dimension widget + validate / cancel icons. + + On degenerate geometry the dimensions and validate hide but cancel stays + visible so the user always has an exit. Radius and trim widgets express + the same single DOF — both read/write the canonical `props.radius`.""" + + bl_idname = "OBJECT_GGT_bim_wall_fillet_preview" + bl_label = "Wall Fillet Preview Gizmos" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + ICON_SCALE: ClassVar[float] = 0.375 + ICON_SPACING_X: ClassVar[float] = 0.4 + ICON_Z_OFFSET: ClassVar[float] = 1.5 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + props = _wall_fillet_props(context) + if props is None or not props.is_active: + return False + if not tool.Blender.are_viewport_gizmos_enabled(): + return False + ifc_file = tool.Ifc.get() + if ifc_file is None: + return False + try: + ifc_file.by_id(props.wall_a_id) + ifc_file.by_id(props.wall_b_id) + except (RuntimeError, KeyError): + return False + return True + + 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]) + + # Lazy-fetched closures re-resolve the Scene per call so the freed-RNA + # crash on file open / undo doesn't hit the gizmo callbacks. + _props_callback = preview_base.make_props_callback("wall_fillet") + + gz = self.gizmos.new("BIM_GT_gizmo_dimension") + gz.move_get_cb = preview_base.make_dim_getter(_props_callback, "radius") + gz.move_set_cb = preview_base.make_dim_setter(_props_callback, "radius") + # Set `axis` only (NOT `local_axis`) so `get_axis_direction` falls + # through to the world-space direction we set in `_position_gizmos`. + # The preview spans world space independent of either wall's local + # frame, so the active-object transform that `local_axis` would go + # through is the wrong frame. + gz.axis = Vector((1, 0, 0)) + gz.invert_delta = False + gz.delta_scale = 1.0 + gz.prop_name = "Radius" + gz.gizmo_group = self + gz.color = default_color + gz.color_highlight = highlight_color + gz.alpha = 1.0 + gz.use_draw_modal = True + gz.use_draw_scale = False + gz.text_offset_sign = 1 + gz.text_alignment = gizmo.TextAlignment.CENTER + # Arrowheads at BOTH ends + extension lines make this read as a proper + # dimension annotation rather than a single-direction drag arrow. + gz.show_start_arrow = True + gz.show_end_arrow = True + gz.show_extension_lines = True + gz.text_formatter = None + self.radius_dim = gz + + # Sweep angle is geometrically invariant during drag (depends only on + # the angle between the two walls). Cached here per-frame from + # `_position_gizmos` so the trim getter / setter can convert + # trim_length ↔ radius via `tan(sweep/2)` without re-running the full + # geometry pipeline on every drag tick. + self._sweep_angle = math.pi / 2 + + # Trim-length widget expresses the SAME single DOF as the radius + # widget via the leg setback distance (intersection → tangent point). + # Architects often think "how much of each wall do I cut back" rather + # than "what radius do I want"; this widget surfaces that mental model + # without introducing a second degree of freedom. Both widgets stay + # in sync because they read/write the same canonical `radius` field. + trim_gz = self.gizmos.new("BIM_GT_gizmo_dimension") + trim_gz.move_get_cb = self._make_trim_getter() + trim_gz.move_set_cb = self._make_trim_setter() + trim_gz.axis = Vector((1, 0, 0)) + trim_gz.invert_delta = False + trim_gz.delta_scale = 1.0 + trim_gz.prop_name = "Trim Length" + trim_gz.gizmo_group = self + trim_gz.color = default_color + trim_gz.color_highlight = highlight_color + trim_gz.alpha = 1.0 + trim_gz.use_draw_modal = True + trim_gz.use_draw_scale = False + trim_gz.text_offset_sign = 1 + trim_gz.text_alignment = gizmo.TextAlignment.CENTER + trim_gz.show_start_arrow = True + trim_gz.show_end_arrow = True + trim_gz.show_extension_lines = True + trim_gz.text_formatter = None + self.trim_dim = trim_gz + + from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup + + self.validate_icon = self.gizmos.new("VIEW3D_GT_validate") + self.validate_icon.use_draw_scale = False + self.validate_icon.color = BaseParametricGizmoGroup.COLOR_GREEN + self.validate_icon.color_highlight = highlight_color + self.validate_icon.target_set_operator("bim.finish_wall_fillet_preview") + + self.cancel_icon = self.gizmos.new("VIEW3D_GT_cancel") + self.cancel_icon.use_draw_scale = False + self.cancel_icon.color = BaseParametricGizmoGroup.COLOR_RED + self.cancel_icon.color_highlight = highlight_color + self.cancel_icon.target_set_operator("bim.cancel_wall_fillet_preview") + + def _make_trim_getter(self): + """Closure returning |radius| * tan(sweep/2) — the live leg setback + distance — from the cached sweep angle and the canonical radius.""" + + def _get() -> float: + props = _wall_fillet_props(bpy.context) + if props is None: + return 0.0 + sweep = max(self._sweep_angle, 1e-3) + return abs(float(props.radius)) * math.tan(sweep / 2.0) + + return _get + + def _make_trim_setter(self): + """Closure writing radius from a dragged trim_length, preserving the + radius sign so a concave preview stays concave when the user drags the + trim widget. Clamps to the FloatProperty's lower bound so the gizmo + can't push radius below the geometry helper's tolerance.""" + + def _set(value: float) -> None: + props = _wall_fillet_props(bpy.context) + if props is None: + return + sweep = max(self._sweep_angle, 1e-3) + tan_half = math.tan(sweep / 2.0) + if tan_half < 1e-9: + return + sign = -1.0 if float(props.radius) < 0 else 1.0 + new_radius = sign * max(0.001, float(value)) / tan_half + props.radius = new_radius + for area in bpy.context.screen.areas if bpy.context.screen else (): + if area.type == "VIEW_3D": + area.tag_redraw() + + return _set + + def refresh(self, context: bpy.types.Context) -> None: + self._position_gizmos(context) + + def draw_prepare(self, context: bpy.types.Context) -> None: + self._position_gizmos(context) + + def _position_gizmos(self, context: bpy.types.Context) -> None: + wall_a_obj, wall_b_obj = _wall_fillet_preview_walls(context) + if wall_a_obj is None or wall_b_obj is None: + for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon): + gz.hide = True + return + + props = _wall_fillet_props(context) + if props is None: + for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon): + gz.hide = True + return + + geom = tool.Wall.compute_wall_fillet_geometry(wall_a_obj, wall_b_obj, props.radius) + billboard_rot = gizmo.get_billboard_rotation(context) + + # Geometry helper failed outright (e.g. wall A's reference line went + # missing). No anchor to draw on — hide everything. + if geom is None: + for gz in (self.radius_dim, self.trim_dim, self.validate_icon, self.cancel_icon): + gz.hide = True + return + + # Parallel / near-collinear axes — no defined arc at all. Drop radius + # + trim + validate; keep cancel visible at the would-be intersection + # so the user has an exit. The dim widgets have nowhere to anchor. + if not geom["valid"] and not geom.get("invalid_radius"): + self.radius_dim.hide = True + self.trim_dim.hide = True + self.validate_icon.hide = True + anchor = None + if geom.get("arc_center") is not None: + anchor = Vector(geom["arc_center"]) + elif geom.get("intersection") is not None: + anchor = Vector(geom["intersection"]) + if anchor is not None: + # Same screen-up lift as the valid branch so the cancel icon + # doesn't sit on top of any underlying preview lines in + # top-down view. + screen_up = gizmo.get_screen_up(billboard_rot) + self.cancel_icon.matrix_basis = gizmo.billboarded_at( + anchor + screen_up * self.ICON_Z_OFFSET, billboard_rot, scale=self.ICON_SCALE + ) + self.cancel_icon.hide = False + else: + self.cancel_icon.hide = True + return + + # Both `valid=True` and `invalid_radius=True` populate arc_center, + # apex, and tangent points. Keep the radius dim visible on overshoot + # so the user can drag back to a valid radius; hide validate so a + # commit can't surface an operator-level error. + invalid_radius = bool(geom.get("invalid_radius")) + self.radius_dim.hide = False + self.trim_dim.hide = False + self.cancel_icon.hide = False + self.validate_icon.hide = invalid_radius + + # Cache the sweep angle so the trim widget's getter / setter can + # convert without re-running the geometry pipeline. Falls back to a + # right angle if the helper somehow omits it. + self._sweep_angle = float(geom.get("sweep_angle") or math.pi / 2) + + arc = geom["arc"] + arc_center = Vector(geom["arc_center"]) + tangent_a = Vector(geom["tangent_a"]) + tangent_b = Vector(geom["tangent_b"]) + intersection = Vector(geom["intersection"]) + + # Radius dimension at the arc apex with local +X pointing INWARD + # toward the arc center. Visual line traces apex → center, matching + # the radius itself; drag in the +X direction (toward arrow tip = + # toward arc center) increases the radius. Anchored at the FLOOR of + # the wall (z=0 of the arc samples) so the gizmo reads against the + # wall geometry rather than hovering in mid-air. + apex_index = len(arc) // 2 + apex = Vector(arc[apex_index]) + inward = arc_center - apex + if inward.length > 1e-6: + inward.normalize() + self.radius_dim.matrix_basis = _wall_fillet_gizmo_x_matrix(apex, inward) + self.radius_dim.axis = inward + self.radius_dim.set_dimension_length(abs(props.radius)) + else: + self.radius_dim.hide = True + + # Trim dimension along wall A from intersection toward tangent_a; + # same DOF as the radius widget, both update `radius`. + along_a = tangent_a - intersection + tangent_offset = abs(float(props.radius)) * math.tan(self._sweep_angle / 2.0) + if along_a.length > 1e-6 and tangent_offset > 1e-6: + along_a_dir = along_a.normalized() + self.trim_dim.matrix_basis = _wall_fillet_gizmo_x_matrix(intersection, along_a_dir) + self.trim_dim.axis = along_a_dir + self.trim_dim.set_dimension_length(tangent_offset) + else: + self.trim_dim.hide = True + + # Validate / cancel anchored ABOVE the arc apex along the camera's + # screen-up direction so they're always visibly clear of the radius + # dim widget (which runs apex → arc_center). Screen-up keeps the + # offset perpendicular to the view plane at any angle — world +Z + # would collapse to zero on-screen in top-down view and plant the + # icons on top of the radius arrowhead. + screen_up = gizmo.get_screen_up(billboard_rot) + anchor = apex + screen_up * self.ICON_Z_OFFSET + offset_x = billboard_rot @ Vector((self.ICON_SPACING_X, 0.0, 0.0)) + self.validate_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot, scale=self.ICON_SCALE) + self.cancel_icon.matrix_basis = gizmo.billboarded_at(anchor + offset_x, billboard_rot, scale=self.ICON_SCALE) + + +class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): + """Pen-icon re-edit gizmo for an existing fillet corner wall. + + Mutually exclusive with an active preview and with GizmoWallEdition.""" + + bl_idname = "OBJECT_GGT_bim_wall_fillet_reedit" + bl_label = "Wall Fillet Re-edit Gizmo" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT"} + + ICON_TOP_LIFT: ClassVar[float] = 0.15 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + if not _wall_gizmo_poll_gate(context): + return False + active = tool.Blender.get_active_object(is_selected=True) + if active is None: + return False + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + return False + element = tool.Ifc.get_entity(active) + if element is None or not element.is_a("IfcWall"): + return False + # IsFilletCorner pset is the authoritative signal — the re-edit + # operator separately verifies both neighbour connections exist and + # reports a user-facing error if either side has been disconnected + # since creation. Validating that here would hide the pen icon + # silently, leaving the user with no obvious next step. + return tool.Parametric.is_fillet_corner_wall(element) + + def setup(self, context: bpy.types.Context) -> None: + prefs = tool.Blender.get_addon_preferences() + default_color = prefs.decorations_colour[:3] + highlight_color = prefs.decorator_color_selected[:3] + self.edit_icon = self.setup_icon_gizmo( + "VIEW3D_GT_pen", + default_color, + highlight_color, + "bim.enable_wall_fillet_preview_from_corner", + ) + + def position_gizmos(self, context: bpy.types.Context) -> None: + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 1: + self.edit_icon.hide = True + return + corner_obj = selected[0] + geom = _get_wall_geom_cached(self, corner_obj) + if geom is None: + self.edit_icon.hide = True + return + billboard_rot = gizmo.get_billboard_rotation(context) + origin = corner_obj.matrix_world.translation + top_z = origin.z + (geom.get("height") or 3.0) + self.ICON_TOP_LIFT + anchor = Vector((origin.x, origin.y, top_z)) + self.edit_icon.matrix_basis = gizmo.billboarded_at(anchor, billboard_rot) + self.edit_icon.hide = False + + class JoinWallsIntersection(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.join_walls_intersection" bl_label = "Join Walls at Corner" @@ -2680,4 +4051,5 @@ class JoinWallsIntersection(bpy.types.Operator, tool.Ifc.Operator): except core.RequireTwoWallsError as e: self.report({"ERROR"}, str(e)) return {"CANCELLED"} + _resync_walls_after_mutation(tool.Blender.get_selected_objects()) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/model/window.py b/src/bonsai/bonsai/bim/module/model/window.py index 2432549661..a14f3322c4 100644 --- a/src/bonsai/bonsai/bim/module/model/window.py +++ b/src/bonsai/bonsai/bim/module/model/window.py @@ -558,8 +558,8 @@ class CycleWindowType(bpy.types.Operator, tool.Ifc.Operator, gizmo.CycleTypeMixi bl_label = "Cycle Window Type" bl_options = {"REGISTER", "UNDO"} - element_checker = "is_window" - props_getter = "get_window_props" + element_checker = tool.Parametric.is_window + props_getter = tool.Model.get_window_props type_literal = tool.Model.WindowType type_attr = "window_type" @@ -745,7 +745,7 @@ class GizmoWindowEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup): DimensionGizmoConfig(attr_name="lining_offset", axis=(0, 1, 0), min_value=-10.0), ] - props_getter = "get_window_props" + props_getter = tool.Model.get_window_props gizmo_pref_name = "window" @classmethod diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 284d427cf3..db6b6389ee 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -63,6 +63,7 @@ import bonsai.core.project as core import bonsai.tool as tool from bonsai.bim import export_ifc, import_ifc from bonsai.bim.ifc import IfcStore +from bonsai.bim.module.model import preview_base from bonsai.bim.module.model.decorator import FaceAreaDecorator, PolylineDecorator from bonsai.bim.module.model.polyline import PolylineOperator from bonsai.bim.module.project.data import LinksData, ProjectLibraryData @@ -1936,6 +1937,10 @@ class ExportIFC(bpy.types.Operator, ExportHelper): def _execute(self, context): committed, failed_commits = tool.Parametric.commit_pending_edits() + # Previews are session-transient — discard rather than commit. Sibling + # gizmo polls gate on each preview's is_active flag, and a stuck flag + # persisted through the save would silently hide them on reload. + preview_base.discard_pending_previews(context.scene) # Suffix is appended to the IFC save-success report below so the auto-commit # info isn't immediately overwritten by the success message in Blender's # status bar (only the latest self.report({"INFO"}, ...) sticks). diff --git a/src/bonsai/bonsai/bim/parametric_lifecycle.py b/src/bonsai/bonsai/bim/parametric_lifecycle.py index 436a28396e..b247e3e4bc 100644 --- a/src/bonsai/bonsai/bim/parametric_lifecycle.py +++ b/src/bonsai/bonsai/bim/parametric_lifecycle.py @@ -71,18 +71,16 @@ from __future__ import annotations import json from collections.abc import Callable -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar, get_args import bpy import ifcopenshell.util.element from bpy.app.handlers import persistent +from ifcopenshell import entity_instance import bonsai.core.geometry import bonsai.tool as tool -if TYPE_CHECKING: - from ifcopenshell import entity_instance - class ParametricEditMixinBase: """Common scaffolding for parametric edit-lifecycle mixins. @@ -379,6 +377,157 @@ class PathPreservingEditMixin(ParametricEditMixinBase): return {"FINISHED"} +# --- Type-selection mixins (Cycle / Pick) ------------------------------------ + + +class TypeAccessorBase: + """Shared contract for operators that resolve and write a Literal type + attribute on a Bonsai PropertyGroup. + + Subclasses define ``element_checker``, ``props_getter``, ``type_literal``, + ``type_attr``; ``skip_element_check`` bypasses element validation. Concrete + subclasses (``CycleTypeMixin``, ``PickTypeMixin``) add the interaction + shape on top. + + Test doubles must be set on the operator instance — the predicates are + bound at class-definition time, so patching the underlying tool module + has no effect.""" + + element_checker: Callable[[entity_instance], bool] + props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup] + type_literal: type + type_attr: str + skip_element_check: bool = False + + def _resolve_target(self, context: bpy.types.Context) -> bpy.types.Object | None: + """Return the active object iff it passes ``element_checker`` (or the + check is skipped). ``None`` signals the operator should bail with + ``{'CANCELLED'}``.""" + obj = context.active_object + if not obj: + return None + if not self.skip_element_check: + element = tool.Ifc.get_entity(obj) + if not element or not self.element_checker(element): + return None + return obj + + +class CycleTypeMixin(TypeAccessorBase): + """Operator mixin that cycles through ``type_literal``'s values. + + Shift-click reverses direction.""" + + 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]: + self.reverse = event.shift + return self.execute(context) + + def _cycle_type(self, context: bpy.types.Context) -> set[str]: + obj = self._resolve_target(context) + if obj is None: + return {"CANCELLED"} + + props = 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 PickTypeMixin(TypeAccessorBase): + """Operator mixin that opens a popup menu listing ``type_literal``'s values. + + Empty ``value`` ⇒ ``invoke`` opens the popup; non-empty ⇒ the user picked + an item and ``_pick_type`` applies it. + + When invoked mid-click (e.g. from a gizmo's ``target_set_operator``), the + menu opens only after the originating ``LEFTMOUSE`` releases. Otherwise + the still-pressed click flows straight into Blender's drag-through-pick + gesture and the menu commits whichever item the cursor drifts over on + release. Other invocation paths (command-palette / F3, EXEC_DEFAULT, F6 + redo) bypass the wait and open the menu immediately. + + The ``value`` StringProperty is declared on this mixin but registered via + the concrete Operator subclass's MRO scan — do not instantiate the mixin + standalone.""" + + # Carries the picked value through invoke→execute; empty default + # distinguishes "open popup" from "apply". + value: bpy.props.StringProperty(default="", options={"HIDDEN", "SKIP_SAVE"}) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: + """Open the picker menu, or apply a value that was preset by a + menu-item click. + + Routing through ``execute()`` keeps subclass IFC-transaction wrapping + in the loop and means F6 redo / ``EXEC_DEFAULT`` reach the apply path.""" + if self.value: + return self.execute(context) + + if self._resolve_target(context) is None: + return {"CANCELLED"} + + if event.value == "PRESS": + context.window_manager.modal_handler_add(self) + return {"RUNNING_MODAL"} + return self._open_picker(context) + + def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: + if event.type == "LEFTMOUSE" and event.value == "RELEASE": + self._open_picker(context) + # INTERFACE does not remove a modal handler; only FINISHED / + # CANCELLED do. + return {"CANCELLED"} + if event.type in {"RIGHTMOUSE", "ESC"}: + return {"CANCELLED"} + return {"RUNNING_MODAL"} + + def _open_picker(self, context: bpy.types.Context) -> set[str]: + bl_idname = self.bl_idname + values = list(get_args(self.type_literal)) + + def draw(menu_self, _menu_context): + layout = menu_self.layout + for v in values: + op = layout.operator(bl_idname, text=v) + op.value = v + + context.window_manager.popup_menu(draw, title=self.bl_label, icon="MENU_PANEL") + # The type change is a two-step interaction: this invocation just OPENS + # the menu (no state change yet); a SECOND invocation fires when the + # user clicks a menu item — that one writes ``props.`` and + # returns FINISHED. By returning INTERFACE here (and not FINISHED), the + # menu-open step is excluded from Blender's undo stack so the user + # gets exactly ONE undo entry per type change. If we returned FINISHED + # here too, the stack would gain a no-op "opened the menu" entry that + # Ctrl+Z would dismiss before reverting the actual type change — + # confusing UX where the first Ctrl+Z appears to do nothing. + return {"INTERFACE"} + + def _pick_type(self, context: bpy.types.Context) -> set[str]: + if not self.value: + # No-op rather than re-open the menu, so command-palette misuse + # doesn't infinite-loop. + return {"CANCELLED"} + + obj = self._resolve_target(context) + if obj is None: + return {"CANCELLED"} + + if self.value not in get_args(self.type_literal): + self.report({"WARNING"}, f"Unknown {self.type_attr}: {self.value!r}") + return {"CANCELLED"} + + props = self.props_getter(obj) + setattr(props, self.type_attr, self.value) + return {"FINISHED"} + + # --- Undo-resync registry ---------------------------------------------------- # # Per-type regenerators called from ``resync_parametric_drafts_after_undo`` diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 33bc310c22..f059b32b6c 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -2871,10 +2871,20 @@ class Model(bonsai.core.tool.Model): @classmethod def recreate_wall(cls, element: ifcopenshell.entity_instance, obj: bpy.types.Object) -> None: - # FIXME(PR4): the fillet-corner branch lands with PR4's - # `regenerate_fillet_corner_wall` (bim/module/model/wall.py). On v0.8.0 - # the function doesn't exist; falling through to the straight-extrusion - # path preserves v0.8.0 behaviour for fillet walls until PR4 ships. + # Curved fillet-corner walls own a hand-built banana body that + # ``regenerate_wall_representation`` would flatten — it reads the axis + # as a 2-point reference line and builds a straight extrusion. Rebuild + # the curve in place instead: ``regenerate_fillet_corner_wall`` keeps + # radius + placement from the pset / current ``ObjectPlacement`` while + # picking up new thickness / height from the wall type, which is what + # we want when a type-property edit triggered this call. + if tool.Parametric.is_fillet_corner_wall(element): + # Lazy import: ``tool.Model`` loads before ``bim/module/model`` at + # addon enable; a module-level import would cycle. + from bonsai.bim.module.model.wall import regenerate_fillet_corner_wall + + regenerate_fillet_corner_wall(element, obj) + return rep = ifcopenshell.api.geometry.regenerate_wall_representation(tool.Ifc.get(), element) bonsai.core.geometry.switch_representation( tool.Ifc, @@ -2909,7 +2919,7 @@ class Model(bonsai.core.tool.Model): if not wall: continue is_layer2_usage = tool.Model.get_usage_type(element) == "LAYER2" - is_fillet_corner = bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner")) + is_fillet_corner = tool.Parametric.is_fillet_corner_wall(element) if not (is_layer2_usage or is_fillet_corner): continue if is_layer2_usage: diff --git a/src/bonsai/bonsai/tool/parametric.py b/src/bonsai/bonsai/tool/parametric.py index ad9846a18d..47a1097bdc 100644 --- a/src/bonsai/bonsai/tool/parametric.py +++ b/src/bonsai/bonsai/tool/parametric.py @@ -487,6 +487,13 @@ class Parametric(bonsai.core.tool.Parametric): return False if tool.Model.get_usage_type(element) == "LAYER2": return True + return cls.is_fillet_corner_wall(element) + + @classmethod + def is_fillet_corner_wall(cls, element: entity_instance) -> bool: + """``True`` if the wall carries the ``BBIM_Wall.IsFilletCorner`` flag, + marking it as a curved corner whose banana body is hand-built rather + than regenerated from the wall's axis + layer set.""" import ifcopenshell.util.element return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner")) diff --git a/src/bonsai/test/bim/module/model/test_fillet_operators.py b/src/bonsai/test/bim/module/model/test_fillet_operators.py new file mode 100644 index 0000000000..2cbc586d18 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_fillet_operators.py @@ -0,0 +1,96 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Behaviour contracts for the wall-fillet operator chain. + +Each fillet operator's geometry path requires real Blender + IFC fixtures +(walls with IfcMaterialLayerSetUsage, neighbour rels, etc.). End-to-end +fillet round-trips belong in the bim feature suite (model.feature) where +that scaffolding already exists. This file pins the surface-level invariants +that don't depend on the geometry path: + + * the lifecycle operators are registered under their conventional bl_idnames, + * the enable poll rejects ineligible selections. + +State-clearing tests via ``bpy.ops.bim.cancel_wall_fillet_preview()`` were +removed because the dispatch is flaky in full-suite ordering — the operator +early-returns when ``context.screen`` is unattached and prior tests can leave +the screen in that state. The behaviour is covered by the user-visible live +test loop instead.""" + +import types + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def _fillet_op_names(): + """Walk bpy.ops.bim for operators whose name contains ``wall_fillet`` — + avoids hard-coding the five lifecycle bl_idnames so adding / renaming + one updates discovery automatically. Each name maps to a callable + operator.""" + return sorted(name for name in dir(bpy.ops.bim) if "wall_fillet" in name) + + +class TestFilletOperatorsRegistered: + """Catches accidental deregistration of any fillet lifecycle operator — + drops in the classes tuple of bim/module/model/__init__.py would otherwise + leave the gizmo group's target_set_operator binding pointing at a missing + op and crash the first time a user clicked the icon.""" + + def test_at_least_the_expected_lifecycle_set_is_registered(self): + names = _fillet_op_names() + # The lifecycle has enable + finish + cancel as a minimum; a healthy + # build also includes the from-corner re-edit entry and the create + # operator the finish dispatches to. The test asserts at least four — + # below that the feature can't function — without enumerating each + # by name, so the test stays meaningful if one is renamed or merged. + assert len(names) >= 4, ( + f"Only {len(names)} fillet operators found on bpy.ops.bim: {names}. " + "The fillet lifecycle needs enable + finish + cancel + create at " + "minimum; check bim/module/model/__init__.py classes tuple." + ) + + def test_every_discovered_fillet_op_is_callable(self): + for name in _fillet_op_names(): + op = getattr(bpy.ops.bim, name) + assert callable(op), f"bpy.ops.bim.{name} is not callable — registration broke?" + + +class TestEnableRejectsIneligibleSelection: + """The preview enable operator requires a specific 2-wall selection + (LAYER2 walls with straight axes). With no selection at all, poll + must return False so the operator is greyed-out in menus instead of + crashing on dispatch.""" + + def test_enable_poll_returns_false_with_no_selection(self): + # Deselect everything in the default scene; no IfcWall is present + # in a fresh bpy_extras context anyway, so poll() must short-circuit. + bpy.ops.object.select_all(action="DESELECT") + bpy.context.view_layer.update() + assert bpy.ops.bim.enable_wall_fillet_preview.poll() is False diff --git a/src/bonsai/test/bim/module/model/test_preview_base.py b/src/bonsai/test/bim/module/model/test_preview_base.py new file mode 100644 index 0000000000..b784ab280e --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_preview_base.py @@ -0,0 +1,178 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Tests for the parametric-edit preview registry contract. + +Every test reads the live ``PREVIEW_CANCEL_OPS`` registry rather than hard- +coding preview keys or cancel-operator names, so adding a new preview to the +registry automatically exercises the same invariants without test changes.""" + +import types + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def _registry(): + from bonsai.bim.module.model.preview_base import PREVIEW_CANCEL_OPS + + return PREVIEW_CANCEL_OPS + + +def _preview_umbrella(): + return getattr(bpy.context.scene, "BIMPreviewProperties", None) + + +def _registered_previews(): + """``[(attr, op_name, props)]`` for every registry entry that has a real + child PropertyGroup on the umbrella in the current addon build.""" + umbrella = _preview_umbrella() + if umbrella is None: + return [] + out = [] + for attr, op_name in _registry(): + props = getattr(umbrella, attr, None) + if props is not None: + out.append((attr, op_name, props)) + return out + + +class TestRegistryContract: + """Pins the invariant that every entry in PREVIEW_CANCEL_OPS resolves to + a real cancel operator the addon registers. A new preview added to the + registry without its matching cancel operator would otherwise crash + ``try_cancel_active_preview`` on the first Esc.""" + + def test_every_registered_cancel_op_is_callable(self): + for attr, op_name in _registry(): + op = getattr(bpy.ops.bim, op_name, None) + assert op is not None and callable(op), ( + f"Preview '{attr}' in PREVIEW_CANCEL_OPS points to bim.{op_name} " + f"but no such operator is registered." + ) + + +class TestGetPreviewPropsTolerance: + """The bug-class fixed in commit ee63137c6: ``get_preview_props`` is called + from gizmo polls during addon init and from test mocks built on + ``SimpleNamespace`` — neither has a fully-formed Blender context. The + helper must return None rather than raise.""" + + def test_returns_none_when_context_has_no_scene(self): + from bonsai.bim.module.model.preview_base import get_preview_props + + # Pass an arbitrary attr name — the contract is the same for every + # preview key, so picking one literally would be a maintenance trap. + for attr, _ in _registry(): + assert get_preview_props(types.SimpleNamespace(), attr) is None + break + + def test_returns_none_when_scene_lacks_umbrella(self): + from bonsai.bim.module.model.preview_base import get_preview_props + + ctx = types.SimpleNamespace(scene=types.SimpleNamespace()) + for attr, _ in _registry(): + assert get_preview_props(ctx, attr) is None + break + + +class TestActivationCycle: + """End-to-end contract on the real addon: each registered preview can be + activated and then cancelled to inactive. Runs for every preview that + has a wired PropertyGroup, so a new preview added to the registry + + umbrella is covered without test edits.""" + + def test_any_preview_active_reflects_each_preview_state(self): + from bonsai.bim.module.model.preview_base import any_preview_active + + registered = _registered_previews() + if not registered: + pytest.skip("No previews wired in this build — registry-only entries") + + # All inactive baseline. + for _, _, props in registered: + props.is_active = False + assert any_preview_active(bpy.context) is False + + # Flip each one independently — the helper must report True. + for _, _, props in registered: + props.is_active = True + assert any_preview_active(bpy.context) is True + props.is_active = False + + def test_discard_pending_previews_clears_every_active_flag(self): + from bonsai.bim.module.model.preview_base import discard_pending_previews + + registered = _registered_previews() + if not registered: + pytest.skip("No previews wired in this build — registry-only entries") + + for _, _, props in registered: + props.is_active = True + discard_pending_previews(bpy.context.scene) + for attr, _, props in registered: + assert props.is_active is False, f"discard_pending_previews left '{attr}' active" + + +class TestSaveOnDiscardWired: + """Pins that the SaveProject operator clears preview state before writing + the IFC file — a stuck is_active flag persisted through the save would + silently hide sister gizmos on the next file load. + + Structural check: the SaveProject operator class must reference the + discard helper somewhere in its execute path. Behavioural integration + (actually saving a .blend with an active preview and reloading) belongs + in the bim feature suite; this is the small guard against accidental + removal of the call site.""" + + def test_save_project_dispatches_discard_pending_previews(self): + import inspect + + from bonsai.bim.module.model import preview_base + from bonsai.bim.module.project import operator as project_operator + + # Find the project save operator dynamically — looking for any + # Operator class whose bl_idname is "bim.save_project". Avoids + # hard-coding the class identifier. + save_op = None + for name in dir(project_operator): + obj = getattr(project_operator, name) + if isinstance(obj, type) and getattr(obj, "bl_idname", None) == "bim.save_project": + save_op = obj + break + assert save_op is not None, "Expected an operator with bl_idname='bim.save_project' in project/operator.py" + + # Walk the class's methods for the discard call. Avoids pinning a + # specific method name (_execute vs execute vs an inner helper) so + # the test survives operator refactors. + source = inspect.getsource(save_op) + assert preview_base.discard_pending_previews.__name__ in source, ( + f"{save_op.__name__} does not reference discard_pending_previews. " + "Saving with a preview open would persist its is_active flag to the " + ".blend file and silently hide sister gizmos on reopen." + ) diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmo_poll_gate.py b/src/bonsai/test/bim/module/model/test_wall_gizmo_poll_gate.py new file mode 100644 index 0000000000..444c0cdf29 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_gizmo_poll_gate.py @@ -0,0 +1,154 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Behaviour contract: every wall gizmo group hides while a parametric-edit +preview is active. + +Enumerates wall gizmo groups by walking the wall module for ``bpy.types.GizmoGroup`` +subclasses rather than naming them — adding a new wall gizmo group automatically +joins the test. The test then asserts the BEHAVIOUR (poll returns False when +``preview_base.any_preview_active`` is True) without pinning the name of the +helper function the gizmo uses internally to enforce it.""" + +import inspect +import types +from unittest.mock import patch + +import bpy +import pytest + +pytestmark = pytest.mark.model + + +@pytest.fixture(autouse=True) +def _require_real_bpy(): + if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"): + pytest.skip("requires real Blender (bpy is mocked or absent)") + + +def _wall_gizmo_groups(): + """Walk the wall module for ``bpy.types.GizmoGroup`` subclasses defined + locally (skip imported references). Returns a list of (name, cls) tuples. + + A gizmo group whose ``poll`` legitimately needs to fire WHILE a preview + is active — i.e. it IS the preview's own gizmo group — is excluded by + convention: classes whose bl_idname references the preview surface + (``preview`` in the idname) are the preview-owner exception.""" + from bonsai.bim.module.model import wall as wall_mod + + out = [] + for name in dir(wall_mod): + obj = getattr(wall_mod, name) + if not isinstance(obj, type): + continue + if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup: + continue + # Local definitions only — skip re-exports / aliases. + if obj.__module__ != wall_mod.__name__: + continue + # Preview-owner exception: the gizmo group that drives a preview + # itself must remain visible while its preview is active, so a + # "no preview active" gate would self-block it. The bl_idname + # contains the substring 'preview' for these groups by Bonsai + # convention (e.g. OBJECT_GGT_bim_wall_fillet_preview). + bl_idname = getattr(obj, "bl_idname", "") or "" + if "preview" in bl_idname.lower(): + continue + out.append((name, obj)) + return out + + +class TestWallGizmoGroupsHideDuringPreview: + """Behaviour contract: a parametric-edit preview is the only interactive + surface in the viewport, so every sister wall gizmo must self-hide via + its poll. The test exercises this BEHAVIOUR — when ``any_preview_active`` + reports True, every wall gizmo's poll returns False — without pinning + the helper function name each poll uses internally.""" + + def test_discovery_finds_wall_gizmo_groups(self): + """Sanity check: at least one wall gizmo group is found. If this fails, + the discovery walk drifted out of sync with the module structure (e.g. + wall gizmo groups got moved to a separate file).""" + groups = _wall_gizmo_groups() + assert groups, "Expected at least one wall GizmoGroup subclass in wall.py — discovery walk broke?" + + def test_every_wall_gizmo_hides_when_a_preview_is_active(self): + """For each discovered wall gizmo group, mock ``any_preview_active`` to + True and call ``poll(bpy.context)``. Every poll must return False — + any True is a poll that wouldn't hide during a fillet/bend preview, + leaving the user with two competing icon stacks on the same selection.""" + groups = _wall_gizmo_groups() + offenders = [] + with patch("bonsai.bim.module.model.preview_base.any_preview_active", return_value=True): + for name, cls in groups: + poll = getattr(cls, "poll", None) + if poll is None: + # Inherits poll from a mixin / base — the base poll's gating + # is covered separately. Skip rather than crash. + continue + try: + result = poll(bpy.context) + except Exception as exc: # noqa: BLE001 + offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}")) + continue + if result: + offenders.append((name, "poll returned True with preview active")) + + assert not offenders, ( + "Wall gizmo polls that don't gate on any_preview_active " + "(or raise instead of returning False): " + + ", ".join(f"{n} — {why}" for n, why in offenders) + + ". Hide sister gizmos during previews so the preview is the only " + "interactive surface in the viewport. The conventional path is to " + "early-return from poll when preview_base.any_preview_active(context) " + "is True." + ) + + +class TestBaseParametricGizmoPollHidesDuringPreview: + """Mirror of the wall-specific test for the cross-feature parametric + framework: door / window / stair / roof / railing / array all inherit + ``BaseParametricGizmoGroup``. Its poll must also short-circuit on + ``any_preview_active`` so sister features behave consistently with walls.""" + + def test_base_parametric_poll_returns_false_when_a_preview_is_active(self): + from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup + + # The base poll requires an active selected object before checking the + # preview gate. Mock both the selected-object check (return a sentinel) + # AND the gate so the test exercises ONLY the preview short-circuit. + with patch("bonsai.tool.Blender.get_active_object", return_value=object()): + with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True): + with patch( + "bonsai.bim.module.model.preview_base.any_preview_active", + return_value=True, + ): + assert BaseParametricGizmoGroup.poll(bpy.context) is False + + +class TestModulePathIsFindable: + """If wall.py is split across multiple modules (e.g. wall_gizmos.py), + update ``_wall_gizmo_groups`` to walk each. This sanity check fails first + so the diagnostic message is obvious.""" + + def test_wall_module_resolves(self): + from bonsai.bim.module.model import wall as wall_mod + + assert inspect.ismodule(wall_mod) diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmos.py b/src/bonsai/test/bim/module/model/test_wall_gizmos.py index 3fd5699ef2..d4474b6e37 100644 --- a/src/bonsai/test/bim/module/model/test_wall_gizmos.py +++ b/src/bonsai/test/bim/module/model/test_wall_gizmos.py @@ -179,3 +179,112 @@ def test_poll_rejects_when_other_is_not_layer2_wall(): _run_poll(prefs_on=True, active_is_in_selected=True, len_override=None, active_usage="LAYER3", other_usage=None) is False ) + + +# ---------------------------------------------------------------------------- +# _iter_path_connections — IfcRelConnectsPathElements inverse-graph walk +# ---------------------------------------------------------------------------- +# +# Normalises both ConnectedTo and ConnectedFrom orientations to (other, self_ct, +# other_ct) so callers always read "self first" regardless of which side of the +# rel this wall was authored on. Non-wall partners and malformed (None) refs are +# filtered out so per-frame gizmo positioning survives partial IFC state. + + +def _make_path_rel(relating, related, relating_ct, related_ct, kind="IfcRelConnectsPathElements"): + """Build a stub IfcRelConnectsPathElements for inverse-walk tests.""" + return SimpleNamespace( + is_a=lambda name, _k=kind: name == _k, + RelatingElement=relating, + RelatedElement=related, + RelatingConnectionType=relating_ct, + RelatedConnectionType=related_ct, + ) + + +def _run_iter_path_connections(elem, *, is_wall_predicate=lambda _e: True): + from bonsai import tool + from bonsai.bim.module.model.wall import _iter_path_connections + + with patch.object(tool.Blender.Modifier, "is_wall", side_effect=is_wall_predicate): + return _iter_path_connections(elem) + + +def test_iter_path_connections_empty_inverses_yields_nothing(): + elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[]) + assert _run_iter_path_connections(elem) == [] + + +def test_iter_path_connections_connected_to_orientation_is_self_first(): + # Self is the rel's RelatingElement → its connection type is RelatingConnectionType. + self_elem = object() + other = object() + rel = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART") + elem = SimpleNamespace(ConnectedTo=[rel], ConnectedFrom=[]) + assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")] + + +def test_iter_path_connections_connected_from_orientation_is_self_first(): + # Self is the rel's RelatedElement → its connection type is RelatedConnectionType. + # The helper must FLIP the tuple so callers still see (other, self_ct, other_ct). + self_elem = object() + other = object() + rel = _make_path_rel(relating=other, related=self_elem, relating_ct="ATSTART", related_ct="ATEND") + elem = SimpleNamespace(ConnectedTo=[], ConnectedFrom=[rel]) + assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")] + + +def test_iter_path_connections_skips_non_path_rels(): + # IfcRelAggregates, IfcRelContainedInSpatialStructure, etc. share the + # ConnectedTo/ConnectedFrom inverse arrays — only IfcRelConnectsPathElements + # carries the per-end connection-type semantics we care about. + self_elem = object() + other = object() + non_path = _make_path_rel( + relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND", kind="IfcRelAggregates" + ) + path = _make_path_rel(relating=self_elem, related=other, relating_ct="ATEND", related_ct="ATSTART") + elem = SimpleNamespace(ConnectedTo=[non_path, path], ConnectedFrom=[]) + assert _run_iter_path_connections(elem) == [(other, "ATEND", "ATSTART")] + + +def test_iter_path_connections_skips_non_wall_partners(): + # Walls may path-connect to non-wall elements (columns, beams). The single- + # wall unjoin gizmo only surfaces wall-to-wall joins to match the existing + # two-wall gizmo's scope. + self_elem = object() + wall_partner = object() + non_wall_partner = object() + rel_wall = _make_path_rel(relating=self_elem, related=wall_partner, relating_ct="ATEND", related_ct="ATSTART") + rel_non_wall = _make_path_rel( + relating=self_elem, related=non_wall_partner, relating_ct="ATEND", related_ct="ATSTART" + ) + elem = SimpleNamespace(ConnectedTo=[rel_wall, rel_non_wall], ConnectedFrom=[]) + result = _run_iter_path_connections(elem, is_wall_predicate=lambda e: e is wall_partner) + assert result == [(wall_partner, "ATEND", "ATSTART")] + + +def test_iter_path_connections_tolerates_none_partner_refs(): + # Malformed / partial IFC files can leave a rel's element ref unset. + # Without a None guard, `Modifier.is_wall(None)` would raise on + # `None.is_a(...)` mid-frame and silently break the gizmo group. + self_elem = object() + other = object() + rel_none = _make_path_rel(relating=self_elem, related=None, relating_ct="ATEND", related_ct="ATSTART") + rel_ok = _make_path_rel(relating=self_elem, related=other, relating_ct="ATSTART", related_ct="ATEND") + elem = SimpleNamespace(ConnectedTo=[rel_none, rel_ok], ConnectedFrom=[]) + assert _run_iter_path_connections(elem) == [(other, "ATSTART", "ATEND")] + + +def test_iter_path_connections_walks_both_inverses_in_order(): + # A wall can sit on both sides of different path rels (e.g. authored once + # as the RelatingElement, once as the RelatedElement). The helper walks + # ConnectedTo first, then ConnectedFrom — pinning the order so callers can + # depend on it for icon-slot allocation. + self_elem = object() + p1 = object() + p2 = object() + rel_to = _make_path_rel(relating=self_elem, related=p1, relating_ct="ATSTART", related_ct="ATSTART") + rel_from = _make_path_rel(relating=p2, related=self_elem, relating_ct="ATEND", related_ct="ATEND") + elem = SimpleNamespace(ConnectedTo=[rel_to], ConnectedFrom=[rel_from]) + assert _run_iter_path_connections(elem) == [(p1, "ATSTART", "ATSTART"), (p2, "ATEND", "ATEND")] diff --git a/src/bonsai/test/bim/module/model/test_wall_header_refresh.py b/src/bonsai/test/bim/module/model/test_wall_header_refresh.py index 933fab2454..41b8719ec5 100644 --- a/src/bonsai/test/bim/module/model/test_wall_header_refresh.py +++ b/src/bonsai/test/bim/module/model/test_wall_header_refresh.py @@ -75,7 +75,7 @@ def test_geom_generation_invalidates_wall_geom_cache(): sentinel_a = {"length": 1.0, "height": 2.0, "x_angle": 0.0} sentinel_b = {"length": 1.5, "height": 2.5, "x_angle": 0.0} - with patch.object(wall_mod, "_read_wall_geometry", side_effect=[sentinel_a, sentinel_b]): + with patch.object(tool.Wall, "read_geometry", side_effect=[sentinel_a, sentinel_b]): first = wall_mod._get_wall_geom_cached(group, fake_obj) assert first is sentinel_a # Same call without a generation bump must hit the cache (no extra read).