From b039e12623dd6852005dca249a7a3a19d3bc444a Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 27 May 2026 23:06:42 +0200 Subject: [PATCH 01/14] Add TypeAccessorBase + CycleTypeMixin + PickTypeMixin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three operator mixins for type-selection ops on parametric features (door type-cycle, window type-pick, stair type-cycle, railing type-pick, roof type-cycle, etc.). Each shares the same contract: * ``element_checker`` validates the active object is the expected IFC type * ``props_getter`` resolves the BIMProperties group * ``type_literal`` is the Literal type whose args drive the enum * ``type_attr`` is the PropertyGroup field to read/write * ``skip_element_check=True`` bypasses element validation (for operators that target a non-IFC context) CycleTypeMixin shift-click reverses direction (forward by default). PickTypeMixin opens a popup menu and routes the picked value through execute() so F6 redo / EXEC_DEFAULT reach the apply path. The PickType modal-handler dance waits for LEFTMOUSE release before opening the menu when invoked mid-click (e.g. from a gizmo's target_set_operator) so Blender's drag-through-pick gesture doesn't commit an accidental item. Ships standalone — the next commit's gizmos.py framework refactor re-exports these names from bonsai.bim.parametric_lifecycle so gizmo modules can spell ``gizmo.CycleTypeMixin`` / ``gizmo.PickTypeMixin``. Concrete operator subclasses land in subsequent PR4 commits per feature (door / window / stair / railing / roof). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/parametric_lifecycle.py | 147 +++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/parametric_lifecycle.py b/src/bonsai/bonsai/bim/parametric_lifecycle.py index 436a28396e..6fa74ab81d 100644 --- a/src/bonsai/bonsai/bim/parametric_lifecycle.py +++ b/src/bonsai/bonsai/bim/parametric_lifecycle.py @@ -71,7 +71,7 @@ from __future__ import annotations import json from collections.abc import Callable -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, ClassVar, get_args import bpy import ifcopenshell.util.element @@ -379,6 +379,151 @@ 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") + # INTERFACE (not FINISHED) keeps the menu-opening invocation out of the + # undo stack; the picked-value write below returns FINISHED, so the + # type change remains undoable as a single step. + 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`` From f1cf757ba28d379255c3ffbe2e511c23ea9cd59b Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Wed, 27 May 2026 23:56:28 +0200 Subject: [PATCH 02/14] =?UTF-8?q?Refactor=20bim/module/drawing/gizmos=20?= =?UTF-8?q?=E2=80=94=20framework=20+=20icon=20infra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three concerns bundled into one cohesive refactor of gizmos.py (splitting them surgically requires intermediate commits with duplicate same-named classes that Python can't parse): 1. Framework primitives — StaticTrisGizmoMixin + TexturedQuadGizmoMixin replace the older TrisGizmoMixin. New module-level helpers: _get_static_tris_shader / _get_static_tris_batch / clear_static_ tris_cache for cached GPU batch reuse, _draw_outline_and_body for the shared outline-then-body render path, draw_tris_with_outline as the public wrapper. billboarded_at(world_pos, billboard_rot, scale) is the canonical billboard-matrix helper; should_flip_extend_ arrow encapsulates the view-aware mirror decision for extend gizmos; get_warning_color_from_prefs reads the user's warning color. 2. Config classes — BaseValueGizmoConfig (shared visibility + dimension- text contract), CountGizmoConfig (array N indicator), DimensionGizmoConfig (length / height / depth labels), IconActionConfig (icon-only gizmos that invoke an operator on click). DimensionRenderer draws the actual numeric label using BLF. 3. Icon classes — each rewritten on StaticTrisGizmoMixin so they share the cached GPU batch + outline-then-body render path: GizmoLockOpen / GizmoLockClosed (replacing the single-state GizmoLock), GizmoArc, GizmoFillet, GizmoWallCornerIcon, GizmoWallTeeIcon, GizmoPen / GizmoValidate / GizmoCancel (the parametric-edit triad), GizmoPlus / GizmoMinus / GizmoTrash, GizmoArrayParent / GizmoArrayAll / GizmoArrayLayerIndicator (array context indicators with a small digit-rendering helper for the "xN" count label), GizmoMerge / GizmoSplit / GizmoUnjoin (wall-join icons), and GizmoMenu (textured-quad icon-action menu trigger). The legacy TrisGizmoMixin, GizmoLock, and DimensionDrawConfig are removed; downstream callers in subsequent PR4 commits swap to the new mixin and config classes when their feature operators land. CycleTypeMixin / PickTypeMixin / TypeAccessorBase live in bim.parametric_lifecycle (previous commit). The three mixins are re-exported from gizmos.py here so feature-module access via ``gizmo.`` keeps working until PR5 cleanup drops the re-exports. bim/module/drawing/__init__.py is updated in the same commit to register the 11 new gizmo classes (GizmoLockOpen / GizmoLockClosed / GizmoFillet / GizmoWallCornerIcon / GizmoWallTeeIcon / GizmoTrash / GizmoArrayParent / GizmoArrayAll / GizmoArrayLayerIndicator / GizmoUnjoin / GizmoMenu) — without that, the new classes exist in gizmos.py but aren't usable as bpy gizmo types. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/__init__.py | 12 +- .../bonsai/bim/module/drawing/gizmos.py | 2569 +++++++++++++---- 2 files changed, 1997 insertions(+), 584 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 8b10314faa..9cda778cc1 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -138,15 +138,24 @@ classes = ( gizmos.GizmoArrow2D, gizmos.GizmoCone, gizmos.GizmoDimension, - gizmos.GizmoLock, + gizmos.GizmoLockOpen, + gizmos.GizmoLockClosed, gizmos.GizmoArc, + gizmos.GizmoFillet, + gizmos.GizmoWallCornerIcon, + gizmos.GizmoWallTeeIcon, gizmos.GizmoPen, gizmos.GizmoValidate, gizmos.GizmoCancel, gizmos.GizmoPlus, gizmos.GizmoMinus, + gizmos.GizmoTrash, + gizmos.GizmoArrayParent, + gizmos.GizmoArrayAll, + gizmos.GizmoArrayLayerIndicator, gizmos.GizmoMerge, gizmos.GizmoSplit, + gizmos.GizmoUnjoin, gizmos.GizmoExtend, gizmos.GizmoExtendVertical, gizmos.GizmoOffsetExterior, @@ -154,6 +163,7 @@ classes = ( gizmos.GizmoOffsetInterior, gizmos.GizmoAddOpening, gizmos.GizmoCycle, + gizmos.GizmoMenu, # Drawing-specific gizmos gizmos.UglyDotGizmo, gizmos.ExtrusionGuidesGizmo, diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 31a0be5c80..55096bc6a0 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -19,73 +19,13 @@ # # This file was modified with the assistance of an AI coding tool. -""" -Gizmo infrastructure for parametric BIM element editing. +"""Viewport gizmos for parametric BIM element editing. -This module provides a framework for interactive 3D gizmos that allow users to -manipulate parametric properties of BIM elements (doors, windows, stairs) directly -in the viewport. - -Architecture Overview -===================== - -The gizmo system follows a configuration-driven approach where element-specific -gizmo groups (e.g., GizmoDoorEdition) inherit from BaseParametricGizmoGroup and -declare their gizmos via configuration dataclasses: - - class GizmoDoorEdition(bpy.types.GizmoGroup, BaseParametricGizmoGroup): - dimension_gizmo_props = [ - DimensionGizmoConfig("overall_width", axis=(1, 0, 0)), - DimensionGizmoConfig("overall_height", axis=(0, 0, 1)), - ] - -Key Components -============== - -Configuration Classes: - - DimensionGizmoConfig: Configures dimension line gizmos with text display - -Base Gizmo Classes: - - GizmoMovable: Base for draggable gizmos with keyboard input support - - GizmoDimension: Dimension line gizmo with arrows and text labels - - GizmoArrow2D: 2D arrow gizmo for property manipulation - -Mixin Classes: - - BaseParametricGizmoGroup: Provides common setup/update methods for gizmo groups - -Utility Classes: - - GPUStateScope: Context manager for GPU state save/restore - - NumericInputState: Tracks keyboard numeric input during modal operations - -Global State: - - _gizmo_modal_context: Module-level dataclass instance for modal operator communication - (workaround for Blender's ID property limitations) - -Data Flow -========= - -1. User selects a parametric element (door, window, stair) -2. GizmoGroup.poll() checks if gizmos should be shown -3. GizmoGroup.setup() creates gizmos based on configs -4. GizmoGroup.refresh() updates gizmo positions from element properties -5. User interacts with gizmo -> invoke() -> modal() -> exit() -6. Property changes are written back via move_set_cb callbacks -7. Element mesh is regenerated via operators (e.g., bim.finish_editing_door) - -Snapping System -=============== - -The module includes a mesh vertex snapping system: - - build_snap_cache(): Builds KD-tree from nearby object vertices - - snap_to_mesh(): Snaps 3D position to nearest vertex within threshold - - Uses screen-space distance filtering for accurate snapping - -View-Dependent Positioning -========================== - -Dimension gizmos automatically reposition based on camera view direction to avoid -overlapping with geometry. The get_local_view_direction() helper determines if the -camera is viewing from the positive or negative side of each axis. +Feature gizmo groups (one per parametric type) declare their gizmos via +``DimensionGizmoConfig`` and inherit shared setup / refresh / snapping +machinery from ``BaseParametricGizmoGroup``. Single-click icons bind to +operators via ``target_set_operator``; drag handles inherit modal state +from ``GizmoMovable``. """ __all__ = [ # noqa: RUF022 (unsorted `__all__`) @@ -95,7 +35,6 @@ __all__ = [ # noqa: RUF022 (unsorted `__all__`) "CoordinateSpace", "ModalState", "DimensionGizmoConfig", - "DimensionDrawConfig", "ViewDirection", "GizmoModalContext", "get_modal_context", @@ -114,20 +53,24 @@ __all__ = [ # noqa: RUF022 (unsorted `__all__`) "create_circle_arc", "BIM_OT_gizmo_value_input", "GizmoMovable", - "GizmoLock", + "GizmoLockOpen", + "GizmoLockClosed", "GizmoArc", "GizmoPen", "GizmoValidate", "GizmoCancel", "GizmoPlus", "GizmoMinus", + "GizmoArrayParent", + "GizmoArrayAll", + "GizmoArrayLayerIndicator", "GizmoCycle", + "GizmoMenu", "GizmoArrow", "GizmoArrow2D", "GizmoCone", "GizmoDimension", "DimensionRenderer", - "CycleTypeMixin", "BaseParametricGizmoGroup", "UglyDotGizmo", "ExtrusionGuidesGizmo", @@ -138,11 +81,12 @@ import math from collections.abc import Callable, Iterator from dataclasses import dataclass from enum import Enum -from typing import Any, Literal, Protocol, get_args, runtime_checkable +from typing import Any, ClassVar, Literal, Protocol, runtime_checkable import blf import bpy import gpu +import ifcopenshell.util.element import numpy as np from bpy import types from bpy_extras import view3d_utils @@ -160,6 +104,16 @@ from mathutils.kdtree import KDTree import bonsai.tool as tool from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader +# Backward-compat re-exports — these mixins moved to bim.parametric_lifecycle +# in the gizmos.py framework refactor. PR4 callers (CycleDoorType / CycleWindowType +# / CycleStairType) still spell gizmo.CycleTypeMixin; the re-export keeps the +# old access path alive until PR4 rewrites the import. PR5 cleanup drops these. +from bonsai.bim.parametric_lifecycle import ( # noqa: F401, E402 + CycleTypeMixin, + PickTypeMixin, + TypeAccessorBase, +) + SNAP_POINT_SIZE = 10.0 SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0) SNAP_MAX_RADIUS = 50.0 @@ -181,6 +135,14 @@ CONE_SEGMENTS = 16 ARC_SEGMENTS = 24 ARC_LINE_WIDTH = 0.015 +# Door-swing arc: start a couple of degrees off the jamb so the arc tip stays +# visible; full quarter-turn for the standard 90-degree swing. +DOOR_SWING_ANGLE_MIN = 2.0 +DOOR_SWING_ANGLE_MAX = 90.0 + +# Default scale factor for billboarded icons (Blender-unit visual size). +DEFAULT_BILLBOARD_SCALE = 0.5 + PRECISION_MODE_MULTIPLIER = 0.1 RAY_CAST_DISTANCE = 1000 @@ -309,9 +271,9 @@ class ModalState(Enum): class GizmoModalContext: """Typed context for modal gizmo operations. - This replaces the untyped dict pattern for passing state between gizmos - and the BIM_OT_gizmo_value_input modal operator. Blender ID properties - don't support function callbacks, so we use this module-level instance. + Passes state between a gizmo and the BIM_OT_gizmo_value_input modal operator. + Blender ID properties cannot carry function callbacks, so a module-level + instance carries them out-of-band. Attributes: move_set_cb: Callback to set the property value @@ -470,9 +432,8 @@ class GPUStateScope: class DimensionTextRenderer: """Handles text rendering for dimension gizmos. - Extracted from GizmoDimension to follow Single Responsibility Principle. - This class manages all text drawing operations including value text, - property tooltips, and text backgrounds. + Manages text drawing operations including value text, property + tooltips, and text backgrounds. Usage: renderer = DimensionTextRenderer.get_instance() @@ -626,50 +587,6 @@ class DimensionTextRenderer: batch.draw(shader) -@dataclass(slots=True, frozen=True) -class DimensionDrawConfig: - """Immutable configuration for drawing a dimension line. - - Groups the many parameters needed by DimensionRenderer.draw() into a - single configuration object, improving readability and maintainability. - - Attributes: - start_world: World-space start position - end_world: World-space end position - axis_world: Normalized axis direction in world space - dimension_length: Length of the dimension (for drawing the line) - color: Base color (r, g, b) - alpha: Base alpha (0.0 to 1.0) - is_highlight: Whether gizmo is highlighted/hovered - highlight_color: Highlight color (r, g, b) - highlight_alpha: Highlight alpha - show_start_arrow: Whether to show arrow at start - show_end_arrow: Whether to show arrow at end - show_extension_lines: Whether to show extension lines - text_offset_sign: 1 for above/right, -1 for below/left - text_alignment: TextAlignment value for text positioning along line - prop_name: Property name for tooltip (shown when highlighted) - display_value: Value to display as text (can be negative); uses dimension_length if None - """ - - start_world: Vector - end_world: Vector - axis_world: Vector - dimension_length: float - color: tuple[float, float, float] = (1.0, 1.0, 1.0) - alpha: float = 1.0 - is_highlight: bool = False - highlight_color: tuple[float, float, float] = (1.0, 1.0, 0.5) - highlight_alpha: float = 1.0 - show_start_arrow: bool = False - show_end_arrow: bool = True - show_extension_lines: bool = True - text_offset_sign: Literal[-1, 1] = 1 - text_alignment: TextAlignment = TextAlignment.CENTER - prop_name: str | None = None - display_value: float | None = None - - @dataclass(slots=True, frozen=True) class ViewDirection: """Immutable representation of camera view direction relative to an element's local space. @@ -738,20 +655,31 @@ class ViewDirection: ) +# Eight unit-length directions for the multi-pass outline shared by every +# icon-class gizmo and by ``DimensionRenderer``'s arrowhead halo. The +# silhouette is rendered once per direction, offset by an outline width +# along that direction; the union approximates a circular dilation — +# a uniform halo on every side. Cardinals are length 1; diagonals use +# sqrt(0.5) components so every direction is at the same Euclidean +# distance from the origin. Uniform scaling around the local origin +# can't replace this: for asymmetric / multi-part geometry it just pushes +# parts further from the origin, which reads as a directional shift +# rather than an outline. +_OUTLINE_DIRECTIONS_8 = ( + (1.0, 0.0), + (-1.0, 0.0), + (0.0, 1.0), + (0.0, -1.0), + (0.7071067811865476, 0.7071067811865476), + (-0.7071067811865476, 0.7071067811865476), + (0.7071067811865476, -0.7071067811865476), + (-0.7071067811865476, -0.7071067811865476), +) + + class DimensionRenderer: - """Handles rendering of dimension line graphics. - - Extracted from GizmoDimension to follow Single Responsibility Principle. - This class manages all dimension drawing operations including lines, - arrows, and extension lines in screen space. - - Usage: - renderer = DimensionRenderer.get_instance() - config = DimensionDrawConfig(start_world, end_world, axis_world, length, color) - renderer.draw(context, config) - # Or use legacy method signature: - renderer.draw(context, start_world, end_world, ...) - """ + """Singleton renderer for dimension line graphics. Draws the dimension + line, end arrows, and extension lines in screen space.""" _instance: "DimensionRenderer | None" = None _line_shader = None @@ -762,6 +690,15 @@ class DimensionRenderer: EXTENSION_LENGTH = 4 LINE_WIDTH = 2.0 MIN_PIXELS_FOR_DETAILS = 35 + # Outline underlay so the dimension stays legible against same-color + # backgrounds (white line on white wall). The line uses a single wider + # dark pass (one extra pixel on each side); the arrowheads use the same + # 8-direction halo technique as icon-class gizmos because a uniform + # widening of a triangle is shape-dependent, not a uniform halo. + OUTLINE_LINE_WIDTH_INCREASE = 2.0 + OUTLINE_LINE_ALPHA = 0.7 + OUTLINE_ARROW_PX = 1.5 + OUTLINE_ARROW_ALPHA = 0.4 @classmethod def get_instance(cls) -> "DimensionRenderer": @@ -917,26 +854,40 @@ class DimensionRenderer: vertices.append(ext_end_bottom) indices.append((idx, idx + 1)) + # Force the main pass fully opaque so the dark outline underlay + # doesn't bleed through and grey out the line/arrows. if is_highlight: - draw_color = (*highlight_color, highlight_alpha) + draw_color = (*highlight_color, 1.0) else: - draw_color = (*color, alpha) + draw_color = (*color, 1.0) with GPUStateScope(depth_test="NONE", blend="ALPHA", ortho_2d=(region.width, region.height)): shader = self._get_line_shader() shader.bind() shader.uniform_float("viewportSize", (region.width, region.height)) - shader.uniform_float("lineWidth", self.LINE_WIDTH) - shader.uniform_float("color", draw_color) line_batch = batch_for_shader(shader, "LINES", {"pos": vertices}, indices=indices) + # Underlay for legibility against same-colour backgrounds. + shader.uniform_float("lineWidth", self.LINE_WIDTH + self.OUTLINE_LINE_WIDTH_INCREASE) + shader.uniform_float("color", (0.0, 0.0, 0.0, self.OUTLINE_LINE_ALPHA)) + line_batch.draw(shader) + shader.uniform_float("lineWidth", self.LINE_WIDTH) + shader.uniform_float("color", draw_color) line_batch.draw(shader) if arrow_triangles: tri_shader = self._get_tri_shader() tri_shader.bind() - tri_shader.uniform_float("color", draw_color) tri_batch = batch_for_shader(tri_shader, "TRIS", {"pos": arrow_triangles}) + # Same eight-direction halo as the icon mixin, in screen-pixel units. + tri_shader.uniform_float("color", (0.0, 0.0, 0.0, self.OUTLINE_ARROW_ALPHA)) + for dx, dy in _OUTLINE_DIRECTIONS_8: + with gpu.matrix.push_pop(): + gpu.matrix.multiply_matrix( + Matrix.Translation((dx * self.OUTLINE_ARROW_PX, dy * self.OUTLINE_ARROW_PX, 0.0)) + ) + tri_batch.draw(tri_shader) + tri_shader.uniform_float("color", draw_color) tri_batch.draw(tri_shader) if length_screen >= self.MIN_PIXELS_FOR_DETAILS: @@ -1078,12 +1029,14 @@ class ParametricProps(Protocol): @dataclass(slots=True) -class DimensionGizmoConfig: - """Configuration for a dimension gizmo. +class BaseValueGizmoConfig: + """Shared scaffolding for every parametric value gizmo (dimensions, counts, …). - Used to declaratively configure dimension line gizmos in BaseParametricGizmoGroup subclasses. - This enables a data-driven approach that reduces boilerplate code for setting up - dimension gizmos with consistent behavior. + Holds the attribute binding, axis/placement hints, color, and read/write hooks + that any value-driven gizmo declared on a ``BaseParametricGizmoGroup`` needs. + Continuous-distance specifics (arrows, text alignment, snap scaling) belong on + ``DimensionGizmoConfig``; integer-stepper specifics belong on the future + ``CountGizmoConfig`` sibling. Color and prop_name are auto-derived if not specified: - axis (1,0,0) or (-1,0,0) -> RED @@ -1091,6 +1044,141 @@ class DimensionGizmoConfig: - axis (0,0,1) or (0,0,-1) -> BLUE - prop_name: "attr_name" -> "Attr Name" (underscores to spaces, title case) + Attributes: + attr_name: Property name to bind to (e.g., "overall_width"). Used to generate + the per-gizmo attribute on the gizmo group. + axis: Direction tuple (x, y, z). Determines color if not specified and defines + the drag/orientation direction. Use negative values for reversed directions. + color: Optional override. One of "RED", "GREEN", "BLUE". Auto-derived from axis. + prop_name: Display name for tooltips. Defaults to attr_name with underscores + replaced by spaces and title-cased. + compute_value: Optional function(props) -> value for computed values. + If None, reads directly from getattr(props, attr_name). + apply_value: Optional function(props, value) to apply new values after edit. + If None, uses setattr(props, attr_name, value). + visibility_condition: Optional function(props) -> bool. If returns False, + the gizmo is hidden. Used for conditional gizmos. + matrix_position: Optional function(props) -> Vector for gizmo position. + The returned Vector is the local-space position where the gizmo origin + will be placed. Combined with axis to create the full transformation matrix. + """ + + attr_name: str + axis: GizmoAxis + color: GizmoColor | str | None = None # GizmoColor enum, string ("RED"/"GREEN"/"BLUE"), or None for auto + prop_name: str | None = None + compute_value: Callable[[Any], Any] | None = None + apply_value: Callable[[Any, Any], None] | None = None + visibility_condition: Callable[[Any], bool] | None = None + # Optional: function(props) -> Vector position. + # + # SUBTLE: presence of this callable doubles as a *trigger* in + # ``BaseParametricGizmoGroup.update_dimension_gizmos`` — when set, the + # gizmo's per-frame matrix is composed via ``compose_gizmo_matrix``, + # which calls ``get_axis_rotation_matrix(self.axis)`` to align the + # gizmo's intrinsic +X direction with ``self.axis`` in object-local + # space. When this is None, the framework falls back to + # ``base_matrix = Identity`` (no axis rotation), and the dimension's + # visual line renders along the object's local +X regardless of + # ``self.axis``. If your dimension's axis is not local +X, you MUST + # pass a ``matrix_position`` callable — even ``lambda _props: Vector((0, 0, 0))`` + # is enough to flip the branch. The wall pattern uses + # ``set_dimension_gizmo_position`` for this; the declarative pattern + # uses ``matrix_position`` for the same effect. + matrix_position: Callable[[Any], "Vector"] | None = None + + def __post_init__(self): + # Validate attr_name + if not self.attr_name or not isinstance(self.attr_name, str): + raise ValueError("attr_name must be a non-empty string") + + # Validate axis + if len(self.axis) != 3: + raise ValueError(f"axis must be a 3-tuple, got {len(self.axis)} elements") + if not any(self.axis): + raise ValueError("axis must have at least one non-zero component") + + # Normalize and validate color + if self.color is None: + # Auto-derive from axis direction + self.color = GizmoColor.from_axis(self.axis) + elif isinstance(self.color, str): + # Convert string to enum + try: + self.color = GizmoColor(self.color) + except ValueError: + raise ValueError(f"color must be 'RED', 'GREEN', or 'BLUE', got '{self.color}'") + elif not isinstance(self.color, GizmoColor): + raise ValueError(f"color must be GizmoColor enum, string, or None, got {type(self.color)}") + + # Auto-derive prop_name from attr_name if not specified + if self.prop_name is None: + self.prop_name = self.attr_name.replace("_", " ").title() + + +@dataclass(slots=True) +class CountGizmoConfig(BaseValueGizmoConfig): + """Configuration for an integer-stepper gizmo (drag-snap-to-int handle). + + Renders as a fixed-size bar (no arrows, no extension lines) with the integer + value as text. Built on top of ``BIM_GT_gizmo_dimension`` — the underlying + gizmo type is reused; only the configuration differs (arrows/extension + lines off, fixed visual length, ``move_set_cb`` wrapped to snap-to-int and + clamp to [min_count, max_count]). + + Examples: + # Basic count - simple integer stepper bound to props.count + CountGizmoConfig( + attr_name="count", + axis=(1, 0, 0), + min_count=1, + max_count=999, + ) + + # With keyboard sensitivity tuning - drag 1m → count += 5 + CountGizmoConfig( + attr_name="count", + axis=(1, 0, 0), + delta_scale=5.0, + ) + + See ``BaseValueGizmoConfig`` for the shared attributes (attr_name, axis, + color, prop_name, compute_value, apply_value, visibility_condition, + matrix_position). + + Count-specific attributes: + min_count: Minimum allowed value when dragging (default 1). + max_count: Maximum allowed value when dragging (default 999). + step: Integer step size; drag values round to nearest multiple of step. + delta_scale: Drag-to-count multiplier. Higher = more counts per meter + of drag. Default 2.0 = roughly half a count per metre, tuned so a + short flick covers small counts without overshoot. + count_formatter: Optional function(props, value) -> str for the count + label. If None, falls back to ``str(int(value))``. + """ + + min_count: int = 1 + max_count: int = 999 + step: int = 1 + delta_scale: float = 2.0 + count_formatter: Callable[[Any, int], str] | None = None + + def __post_init__(self): + BaseValueGizmoConfig.__post_init__(self) + if self.min_count > self.max_count: + raise ValueError(f"min_count {self.min_count} must be <= max_count {self.max_count}") + if self.step < 1: + raise ValueError(f"step must be >= 1, got {self.step}") + + +@dataclass(slots=True) +class DimensionGizmoConfig(BaseValueGizmoConfig): + """Configuration for a continuous-float dimension line gizmo. + + Used to declaratively configure dimension line gizmos in BaseParametricGizmoGroup subclasses. + This enables a data-driven approach that reduces boilerplate code for setting up + dimension gizmos with consistent behavior. + Examples: # Basic dimension - uses attr_name to read/write property DimensionGizmoConfig( @@ -1114,31 +1202,23 @@ class DimensionGizmoConfig: visibility_condition=lambda props: props.nosing_length > 0, ) - Attributes: - attr_name: Property name to bind to (e.g., "overall_width"). Used to generate - gizmo attribute name as f"dimension_{attr_name}_gizmo". - axis: Direction tuple (x, y, z) for the dimension line. Determines color if not - specified and defines drag direction. Use negative values for reversed directions. - color: Optional override. One of "RED", "GREEN", "BLUE". Auto-derived from axis. - prop_name: Display name for tooltips. Defaults to attr_name with underscores - replaced by spaces and title-cased. - min_value: Minimum allowed value when dragging (default 0.0). + See ``BaseValueGizmoConfig`` for the shared attributes (attr_name, axis, color, + prop_name, compute_value, apply_value, visibility_condition, matrix_position). + + Dimension-specific attributes: + min_value: Lower bound the default ``attr_name`` setter clamps to + before writing (default 0.0 — the floor for natural non-negative + dimensions like ``wall_thickness``, ``casing_thickness``, + ``overall_width``). Only consulted when ``apply_value`` is None; + when a custom ``apply_value`` is supplied, the callback owns any + bounding (it can pass through, absolutise, or reproject the sign + as needed). invert_delta: If True, reverses the drag direction effect. delta_scale: Multiplier for drag delta (default 1.0). Use <1 for fine control. text_offset_sign: 1 or -1 to position text above/below dimension line. text_alignment: "start", "center", or "end" for text positioning along line. show_start_arrow: Whether to show arrow at start point (default False). show_end_arrow: Whether to show arrow at end point (default True). - compute_value: Optional function(props) -> float for computed dimension values. - If None, reads directly from getattr(props, attr_name). - apply_value: Optional function(props, value) to apply new values after drag. - If None, uses setattr(props, attr_name, value). - visibility_condition: Optional function(props) -> bool. If returns False, - the gizmo is hidden. Used for conditional gizmos. - matrix_position: Optional function(props) -> Vector for gizmo position. - If provided, eliminates need for get_dimension_matrix_{attr_name} method. - The returned Vector is the local-space position where the gizmo origin - will be placed. Combined with axis to create the full transformation matrix. text_formatter: Optional function(props, value) -> str for the dimension label. Receives the props bag and the post-`compute_value` display value (i.e. the same number `apply_value` consumes during drag — for the @@ -1148,10 +1228,6 @@ class DimensionGizmoConfig: `tool.Unit.format_distance(abs(value))` with negative-sign handling. """ - attr_name: str - axis: GizmoAxis - color: GizmoColor | str | None = None # GizmoColor enum, string ("RED"/"GREEN"/"BLUE"), or None for auto - prop_name: str | None = None min_value: float = 0.0 invert_delta: bool = False delta_scale: float = 1.0 @@ -1159,22 +1235,15 @@ class DimensionGizmoConfig: text_alignment: TextAlignment | str = TextAlignment.CENTER show_start_arrow: bool = False show_end_arrow: bool = True - compute_value: Callable[[Any], float] | None = None - apply_value: Callable[[Any, float], None] | None = None - visibility_condition: Callable[[Any], bool] | None = None - matrix_position: Callable[[Any], "Vector"] | None = None # Optional: function(props) -> Vector position text_formatter: Callable[[Any, float], str] | None = None # Optional: function(props, value) -> label text + schematic_visible_length: float | None = None # Override the schematic group's default tag length for this dim. + # In-place dimensions ignore this — it only affects schematic-group rendering. def __post_init__(self): - # Validate attr_name - if not self.attr_name or not isinstance(self.attr_name, str): - raise ValueError("attr_name must be a non-empty string") - - # Validate axis - if len(self.axis) != 3: - raise ValueError(f"axis must be a 3-tuple, got {len(self.axis)} elements") - if not any(self.axis): - raise ValueError("axis must have at least one non-zero component") + # @dataclass(slots=True) rebinds the class in module namespace, leaving super()'s + # implicit __class__ cell pointing at the pre-decorator class. Call the parent + # __post_init__ directly to avoid the resulting TypeError. + BaseValueGizmoConfig.__post_init__(self) # Normalize and validate text_alignment if isinstance(self.text_alignment, str): @@ -1190,23 +1259,6 @@ class DimensionGizmoConfig: if self.text_offset_sign not in (1, -1): raise ValueError(f"text_offset_sign must be 1 or -1, got {self.text_offset_sign}") - # Normalize and validate color - if self.color is None: - # Auto-derive from axis direction - self.color = GizmoColor.from_axis(self.axis) - elif isinstance(self.color, str): - # Convert string to enum - try: - self.color = GizmoColor(self.color) - except ValueError: - raise ValueError(f"color must be 'RED', 'GREEN', or 'BLUE', got '{self.color}'") - elif not isinstance(self.color, GizmoColor): - raise ValueError(f"color must be GizmoColor enum, string, or None, got {type(self.color)}") - - # Auto-derive prop_name from attr_name if not specified - if self.prop_name is None: - self.prop_name = self.attr_name.replace("_", " ").title() - def __repr__(self) -> str: """Concise representation showing key configuration values.""" parts = [f"attr_name={self.attr_name!r}", f"axis={self.axis}"] @@ -1225,6 +1277,20 @@ class DimensionGizmoConfig: return f"DimensionGizmoConfig({', '.join(parts)})" +@dataclass(slots=True) +class IconActionConfig: + """Declarative config for a single icon-action gizmo (one-shot click, + no value, no drag state). + + ``visibility_condition``: optional ``(obj) -> bool`` predicate hiding + this one icon. ``None`` means always visible while the group is polled.""" + + name: str + icon: str + operator: str + visibility_condition: Callable[[Any], bool] | None = None + + class SnapManager: """Manages snap point visualization and mesh snapping with caching.""" @@ -1602,13 +1668,32 @@ def get_billboard_rotation(context: bpy.types.Context) -> Matrix: return rv3d.view_matrix.to_3x3().transposed().to_4x4() -def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = 0.5) -> Matrix: - """Compose the standard icon ``matrix_basis``: translate to ``world_pos``, billboard - to the camera, then uniformly scale. Replaces the repeated - ``Matrix.Translation(...) @ billboard_rot @ Matrix.Scale(scale, 4)`` pattern.""" +def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = DEFAULT_BILLBOARD_SCALE) -> Matrix: + """Compose the standard icon matrix_basis: translate to ``world_pos``, billboard to the camera, + then uniformly scale.""" return Matrix.Translation(world_pos) @ billboard_rot @ Matrix.Scale(scale, 4) +# Dead-band on the screen-X delta — prevents flicker when the gizmo sits on the +# element origin. +EXTEND_FLIP_EPSILON = 1e-4 + +# Post-multipliers that mirror a billboarded matrix about its local X / Y axis. +EXTEND_FLIP_MIRROR_X = Matrix.Diagonal(Vector((-1.0, 1.0, 1.0, 1.0))) +EXTEND_FLIP_MIRROR_Y = Matrix.Diagonal(Vector((1.0, -1.0, 1.0, 1.0))) + + +def should_flip_extend_arrow( + gizmo_world: Vector, + reference_world: Vector, + billboard_rot: Matrix, +) -> bool: + """True when ``reference_world`` projects to screen-right of ``gizmo_world`` — + mirror the extend arrow's local X so it points away from the reference in screen space.""" + screen_delta = billboard_rot.transposed() @ (reference_world - gizmo_world) + return screen_delta.x > EXTEND_FLIP_EPSILON + + def setup_icon_gizmo( gizmo_group: bpy.types.GizmoGroup, gizmo_type: str, @@ -1617,9 +1702,8 @@ def setup_icon_gizmo( operator: str, alpha: float = 0.8, ) -> bpy.types.Gizmo: - """Create and configure a stand-alone icon gizmo with the Bonsai defaults - (no draw-scale, fixed alpha, click-to-operator). Use this from any - ``GizmoGroup.setup`` to avoid hand-rolling the same five property assignments.""" + """Create an icon gizmo with the Bonsai defaults (no draw-scale, fixed + alpha, click-to-operator).""" gizmo = gizmo_group.gizmos.new(gizmo_type) gizmo.use_draw_scale = False gizmo.color = color @@ -1629,6 +1713,11 @@ def setup_icon_gizmo( return gizmo +def get_warning_color_from_prefs(prefs) -> tuple[float, float, float]: + """Hover color for destructive gizmo icons (split, unjoin, delete).""" + return prefs.decorator_color_error[:3] + + # --- Tris geometry helpers ---------------------------------------------------- # Shared by the icon ``bpy.types.Gizmo`` subclasses defined later in this module. # Each gizmo declares a flat ``tris`` tuple of (x, y, z) vertices grouped into @@ -1657,23 +1746,215 @@ def swap_xy_tris( return tuple((y, x, z) for x, y, z in tris) -class TrisGizmoMixin: - """Mixin for stand-alone ``bpy.types.Gizmo`` classes whose only behaviour is - drawing a static ``tris`` triangle tuple. Subclasses set the class-level - ``tris`` and ``bl_idname`` attributes; the mixin supplies ``setup`` / ``draw`` / - ``draw_select``. Use only with gizmos that have no per-instance state beyond - ``custom_shape``.""" +# Module-level GPU caches for StaticTrisGizmoMixin. Batches are keyed by +# concrete subclass (each has its own ``tris``); the shader is a single +# UNIFORM_COLOR instance shared across all icon-class gizmos. Both must be +# cleared on addon unregister + ``load_post`` because GPUBatch / GPUShader +# references hold GPU resources that go stale across blend-file reloads. +_static_tris_batches: dict[type, "gpu.types.GPUBatch"] = {} +_static_tris_shader = None + + +def _get_static_tris_shader(): + global _static_tris_shader + if _static_tris_shader is None: + _static_tris_shader = gpu.shader.from_builtin("UNIFORM_COLOR") + return _static_tris_shader + + +def _get_static_tris_batch(cls): + batch = _static_tris_batches.get(cls) + if batch is None: + batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": cls.tris}) + _static_tris_batches[cls] = batch + return batch + + +def clear_static_tris_cache() -> None: + """Drops the cached per-class TRIS batches and shader. Wired into addon + teardown + ``load_post`` so GPU resources don't outlive their context.""" + global _static_tris_shader + _static_tris_batches.clear() + _static_tris_shader = None + + +# Single source of truth for icon-class outline defaults. Referenced from +# both ``StaticTrisGizmoMixin`` (class-attribute defaults a concrete gizmo +# can override per-class) and ``draw_tris_with_outline`` (helper called +# from dynamic-tris gizmos that don't inherit the mixin). ``_OUTLINE_DIRECTIONS_8`` +# lives near ``DimensionRenderer`` because both consumers reference it. +_OUTLINE_DEFAULT_WIDTH = 0.03 +_OUTLINE_DEFAULT_ALPHA = 0.4 + + +def _draw_outline_and_body( + shader: "gpu.types.GPUShader", + batch: "gpu.types.GPUBatch", + base_matrix: Matrix, + color: tuple[float, float, float, float], + outline_width: float, + outline_alpha: float, +) -> None: + """Renders 8 outline passes (semi-transparent black, offset by + ``outline_width`` in the cardinal + diagonal unit directions) followed + by the body pass at ``color``, wrapped in ALPHA blend state. + + Caller must bind the shader and configure any sampler / texture + uniforms before calling. The ``color`` uniform is set internally for + each pass — caller's ``color`` uniform is overwritten.""" + with GPUStateScope(blend="ALPHA"): + if outline_alpha > 0.0 and outline_width > 0.0: + shader.uniform_float("color", (0.0, 0.0, 0.0, outline_alpha)) + for dx, dy in _OUTLINE_DIRECTIONS_8: + offset_matrix = base_matrix @ Matrix.Translation((dx * outline_width, dy * outline_width, 0.0)) + with gpu.matrix.push_pop(): + gpu.matrix.multiply_matrix(offset_matrix) + batch.draw(shader) + shader.uniform_float("color", color) + with gpu.matrix.push_pop(): + gpu.matrix.multiply_matrix(base_matrix) + batch.draw(shader) + + +def draw_tris_with_outline( + batch: "gpu.types.GPUBatch", + base_matrix: Matrix, + color: tuple[float, float, float, float], + outline_width: float = _OUTLINE_DEFAULT_WIDTH, + outline_alpha: float = _OUTLINE_DEFAULT_ALPHA, +) -> None: + """Renders ``batch`` as an opaque tris body with an 8-way dark halo behind. + + Shared between StaticTrisGizmoMixin and custom-draw gizmos with dynamic + tris. The caller supplies the per-frame matrix and the icon color; this + routine handles shader binding, the eight outline passes, the body + pass, and the surrounding GPU blend state.""" + shader = _get_static_tris_shader() + shader.bind() + _draw_outline_and_body(shader, batch, base_matrix, color, outline_width, outline_alpha) + + +class StaticTrisGizmoMixin: + """Mixin for gizmos drawing a static class-level ``tris`` tuple. + + Renders the icon nine times: eight outline passes (the silhouette in + semi-transparent black, offset by ``outline_width`` in eight unit-length + directions), then the icon itself at its normal color. The union of the + eight offset silhouettes approximates a circular dilation of the icon, + producing a uniform dark halo on every side — keeps glyphs legible on + any background (white walls, white mesh, dark theme, dark mesh). + Disable per-class with ``outline_alpha = 0.0`` or ``outline_width = 0``.""" + + # Outline ring width in local tris coordinates. The existing tris span + # roughly ±0.3 to ±0.45 in local XY; 0.03 produces a ~6–10% halo on + # every side, readable on any background without crowding the glyph. + outline_width: float = _OUTLINE_DEFAULT_WIDTH + # Per-pass alpha. Eight overlapping passes accumulate where they meet, + # so 0.4 per pass produces a near-opaque inner ring (~0.98 cumulative) + # and a clearly visible outer fade (single-pass 0.4 at the dilation edge). + outline_alpha: float = _OUTLINE_DEFAULT_ALPHA + # When True, hit shape is the glyph's 2D bounding box (plus ``outline_width`` + # padding) — clickable surface matches the visible tile, no dead zones. + # Subclasses used in tight stacks (where adjacent icons sit closer than the + # bbox extent) should set this False so each icon's hit area stays inside + # its glyph and adjacent icons don't steal each other's clicks. + hit_uses_bbox: bool = True def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) + if self.hit_uses_bbox: + xs = [v[0] for v in self.tris] + ys = [v[1] for v in self.tris] + pad = self.outline_width + hit_tris = rect_tris(min(xs) - pad, min(ys) - pad, max(xs) + pad, max(ys) + pad) + else: + hit_tris = self.tris + self.custom_shape = self.new_custom_shape("TRIS", hit_tris) def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) + # Icon body is forced fully opaque: any ``self.alpha`` < 1.0 would + # let the dark outline behind bleed through and grey out the glyph. + # Hover-vs-default is conveyed by RGB only. + if self.is_highlight: + color = (*self.color_highlight, 1.0) + else: + color = (*self.color, 1.0) + draw_tris_with_outline( + _get_static_tris_batch(type(self)), + self.matrix_basis @ self.matrix_offset, + color, + self.outline_width, + self.outline_alpha, + ) def draw_select(self, context: bpy.types.Context, select_id: int) -> None: self.draw_custom_shape(self.custom_shape, select_id=select_id) +# Unit quad in the Z=0 plane — same local space as icon-class ``tris`` tuples, +# so ``matrix_basis`` / ``scale_basis`` position it identically. +_TEXTURED_QUAD_POSITIONS = ( + (-0.5, -0.5, 0.0), + (0.5, -0.5, 0.0), + (0.5, 0.5, 0.0), + (-0.5, 0.5, 0.0), +) +_TEXTURED_QUAD_TEX_COORDS = ( + (0.0, 0.0), + (1.0, 0.0), + (1.0, 1.0), + (0.0, 1.0), +) + + +class TexturedQuadGizmoMixin(StaticTrisGizmoMixin): + """Renders a billboarded textured quad from ``bim/data/icons/.png``. + + Inherits ``StaticTrisGizmoMixin`` on purpose: ``draw_select`` and the + tris fallback stay available. Any texture failure (missing PNG, GPU + init error, mid-reload race) falls through to ``super().draw`` so the + gizmo never disappears. ``outline_scale`` / ``outline_alpha`` are + inherited from the parent and apply identically — IMAGE_COLOR multiplies + the sampled texel by the uniform color, so a black-tinted scaled-up pass + produces a dark halo around the PNG silhouette.""" + + icon_name: str = "" + + def setup(self) -> None: + super().setup() + from bonsai.bim.module.drawing import gizmo_textures + + self._quad_batch = batch_for_shader( + gizmo_textures.get_shader(), + "TRI_FAN", + {"pos": _TEXTURED_QUAD_POSITIONS, "texCoord": _TEXTURED_QUAD_TEX_COORDS}, + ) + + def draw(self, context: bpy.types.Context) -> None: + from bonsai.bim.module.drawing import gizmo_textures + + texture = gizmo_textures.get_icon_texture(self.icon_name) + if texture is None: + super().draw(context) + return + shader = gizmo_textures.get_shader() + # Icon body forced fully opaque so the dark outline behind doesn't + # bleed through the texture and grey out the glyph. + if self.is_highlight: + color = (*self.color_highlight, 1.0) + else: + color = (*self.color, 1.0) + shader.bind() + shader.uniform_sampler("image", texture) + _draw_outline_and_body( + shader, + self._quad_batch, + self.matrix_basis @ self.matrix_offset, + color, + self.outline_width, + self.outline_alpha, + ) + + def get_camera_direction(context: bpy.types.Context, position: Vector) -> Vector | None: """Get normalized direction from position towards camera.""" rv3d = context.region_data @@ -2045,18 +2326,14 @@ class OffsetHandle: return {"CANCELLED"} delta = coordz - self.init_coordz if "PRECISE" in tweak: - delta /= 10.0 + delta *= PRECISION_MODE_MULTIPLIER value = max(0, self.init_value + delta) value *= self.scale_value - # ctx.area.header_text_set(f"coords: {self.init_coordz} - {coordz}, delta: {delta}, value: {value}") ctx.area.header_text_set(f"Depth: {value}") self.target_set_value("offset", value) return {"RUNNING_MODAL"} def project_mouse(self, ctx, event): - """Projecting mouse coords to local axis Z""" - # logic from source/blender/editors/gizmo_library/gizmo_types/arrow3d_gizmo.c:gizmo_arrow_modal - mouse = Vector((event.mouse_region_x, event.mouse_region_y)) region = ctx.region region3d = ctx.region_data @@ -2124,7 +2401,6 @@ class ExtrusionGuidesGizmo(CustomGizmo, types.Gizmo): __slots__ = ("scale_value", "custom_shape") def setup(self): - """setup `custom_shape`""" shader_wrapper = ExtrusionGuidesShader() verts = [Vector((0, 0, 0)), Vector((0, 0, 1))] verts, edges = shader_wrapper.process_geometry(verts) @@ -2195,7 +2471,6 @@ class ExtrusionWidget(types.GizmoGroup): gz.scale_value = scale_value def refresh(self, context: bpy.types.Context) -> None: - """updating gizmos""" target = context.active_object if not target: return @@ -2204,7 +2479,6 @@ class ExtrusionWidget(types.GizmoGroup): self.guides.matrix_basis = basis def update(self, context: bpy.types.Context) -> None: - """updating object""" bpy.ops.bim.update_parametric_representation() target = context.active_object if not target: @@ -2513,6 +2787,16 @@ class GizmoMovable(bpy.types.Gizmo): # Threshold in pixels for considering mouse movement as a drag DRAG_THRESHOLD = 5 + def _get_triangles(self) -> tuple[tuple[float, float, float], ...]: + """Subclasses must return TRIS-mode geometry for the custom shape.""" + raise NotImplementedError(f"{type(self).__name__} must define _get_triangles()") + + def setup(self) -> None: + self.custom_shape = self.new_custom_shape("TRIS", self._get_triangles()) + + def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self.draw_custom_shape(self.custom_shape, select_id=select_id) + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set: self.init_value = self.move_get_cb() if self.move_get_cb else 0.0 self.start_location = self.matrix_basis.translation.copy() @@ -2800,172 +3084,265 @@ class GizmoMovable(bpy.types.Gizmo): blf.disable(font_id, blf.SHADOW) -class GizmoLock(bpy.types.Gizmo): - """Lock icon gizmo that switches between closed and open states.""" +LOCK_TRIS_OPEN = ( + (-0.12838619947433472, 1.3143587112426758, 0.0), + (0.025773197412490845, 1.411454677581787, 0.0), + (-0.0144234299659729, 1.541273593902588, 0.0), + (-0.0144234299659729, 1.541273593902588, 0.0), + (0.025773197412490845, 1.411454677581787, 0.0), + (0.20782703161239624, 1.4184625148773193, 0.0), + (0.23792517185211182, 1.5509872436523438, 0.0), + (0.20782703161239624, 1.4184625148773193, 0.0), + (0.3689943850040436, 1.3335046768188477, 0.0), + (0.4613226056098938, 1.433225393295288, 0.0), + (0.3689943850040436, 1.3335046768188477, 0.0), + (0.4660903215408325, 1.1793451309204102, 0.0), + (0.5959094166755676, 1.2195416688919067, 0.0), + (0.4660903215408325, 1.1793451309204102, 0.0), + (0.47309836745262146, 0.997291088104248, 0.0), + (0.6056233048439026, 0.9671931266784668, 0.0), + (0.47309836745262146, 0.997291088104248, 0.0), + (0.3881405293941498, 0.8361238241195679, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (0.48786139488220215, 4.5077928945147505e-08, 0.0), + (0.48786139488220215, 0.7437955141067505, 0.0), + (-0.12838619947433472, 1.3143587112426758, 0.0), + (-0.0144234299659729, 1.541273593902588, 0.0), + (-0.22810709476470947, 1.406686782836914, 0.0), + (-0.0144234299659729, 1.541273593902588, 0.0), + (0.20782703161239624, 1.4184625148773193, 0.0), + (0.23792517185211182, 1.5509872436523438, 0.0), + (0.23792517185211182, 1.5509872436523438, 0.0), + (0.3689943850040436, 1.3335046768188477, 0.0), + (0.4613226056098938, 1.433225393295288, 0.0), + (0.4613226056098938, 1.433225393295288, 0.0), + (0.4660903215408325, 1.1793451309204102, 0.0), + (0.5959094166755676, 1.2195416688919067, 0.0), + (0.5959094166755676, 1.2195416688919067, 0.0), + (0.47309836745262146, 0.997291088104248, 0.0), + (0.6056233048439026, 0.9671931266784668, 0.0), + (0.6056233048439026, 0.9671931266784668, 0.0), + (0.3881405293941498, 0.8361238241195679, 0.0), + (0.48786142468452454, 0.74379563331604, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (-0.48786139488220215, 4.5077928945147505e-08, 0.0), + (0.48786139488220215, 4.5077928945147505e-08, 0.0), +) - bl_idname = "VIEW3D_GT_lock" - - __slots__ = ( - "custom_shape_closed", - "custom_shape_open", - "prop_path", - ) - - tris_closed = ( - (-0.12838619947433472, 1.3143587112426758, 0.0), - (0.025773197412490845, 1.411454677581787, 0.0), - (-0.0144234299659729, 1.541273593902588, 0.0), - (-0.0144234299659729, 1.541273593902588, 0.0), - (0.025773197412490845, 1.411454677581787, 0.0), - (0.20782703161239624, 1.4184625148773193, 0.0), - (0.23792517185211182, 1.5509872436523438, 0.0), - (0.20782703161239624, 1.4184625148773193, 0.0), - (0.3689943850040436, 1.3335046768188477, 0.0), - (0.4613226056098938, 1.433225393295288, 0.0), - (0.3689943850040436, 1.3335046768188477, 0.0), - (0.4660903215408325, 1.1793451309204102, 0.0), - (0.5959094166755676, 1.2195416688919067, 0.0), - (0.4660903215408325, 1.1793451309204102, 0.0), - (0.47309836745262146, 0.997291088104248, 0.0), - (0.6056233048439026, 0.9671931266784668, 0.0), - (0.47309836745262146, 0.997291088104248, 0.0), - (0.3881405293941498, 0.8361238241195679, 0.0), - (-0.48786139488220215, 0.7437955141067505, 0.0), - (0.48786139488220215, 4.5077928945147505e-08, 0.0), - (0.48786139488220215, 0.7437955141067505, 0.0), - (-0.12838619947433472, 1.3143587112426758, 0.0), - (-0.0144234299659729, 1.541273593902588, 0.0), - (-0.22810709476470947, 1.406686782836914, 0.0), - (-0.0144234299659729, 1.541273593902588, 0.0), - (0.20782703161239624, 1.4184625148773193, 0.0), - (0.23792517185211182, 1.5509872436523438, 0.0), - (0.23792517185211182, 1.5509872436523438, 0.0), - (0.3689943850040436, 1.3335046768188477, 0.0), - (0.4613226056098938, 1.433225393295288, 0.0), - (0.4613226056098938, 1.433225393295288, 0.0), - (0.4660903215408325, 1.1793451309204102, 0.0), - (0.5959094166755676, 1.2195416688919067, 0.0), - (0.5959094166755676, 1.2195416688919067, 0.0), - (0.47309836745262146, 0.997291088104248, 0.0), - (0.6056233048439026, 0.9671931266784668, 0.0), - (0.6056233048439026, 0.9671931266784668, 0.0), - (0.3881405293941498, 0.8361238241195679, 0.0), - (0.48786142468452454, 0.74379563331604, 0.0), - (-0.48786139488220215, 0.7437955141067505, 0.0), - (-0.48786139488220215, 4.5077928945147505e-08, 0.0), - (0.48786139488220215, 4.5077928945147505e-08, 0.0), - ) - - tris_open = ( - (-0.3519617021083832, 0.7437955141067505, 0.0), - (-0.3048076927661896, 0.9197763204574585, 0.0), - (-0.4225003123283386, 0.9877263307571411, 0.0), - (-0.4225003123283386, 0.9877263307571411, 0.0), - (-0.3048076927661896, 0.9197763204574585, 0.0), - (-0.1759808510541916, 1.0486031770706177, 0.0), - (-0.24393069744110107, 1.1662957668304443, 0.0), - (-0.1759808510541916, 1.0486031770706177, 0.0), - (2.9078805141580233e-08, 1.0957571268081665, 0.0), - (2.9078805141580233e-08, 1.2316569089889526, 0.0), - (2.9078805141580233e-08, 1.0957571268081665, 0.0), - (0.1759808510541916, 1.0486031770706177, 0.0), - (0.243930846452713, 1.1662957668304443, 0.0), - (0.1759808510541916, 1.0486031770706177, 0.0), - (0.30480796098709106, 0.9197763204574585, 0.0), - (0.4225005805492401, 0.9877263307571411, 0.0), - (0.30480796098709106, 0.9197763204574585, 0.0), - (0.35196200013160706, 0.7437955141067505, 0.0), - (-0.48786139488220215, 0.7437955141067505, 0.0), - (0.48786139488220215, 4.5077928945147505e-08, 0.0), - (0.48786139488220215, 0.7437955141067505, 0.0), - (-0.3519617021083832, 0.7437955141067505, 0.0), - (-0.4225003123283386, 0.9877263307571411, 0.0), - (-0.48786139488220215, 0.7437955141067505, 0.0), - (-0.4225003123283386, 0.9877263307571411, 0.0), - (-0.1759808510541916, 1.0486031770706177, 0.0), - (-0.24393069744110107, 1.1662957668304443, 0.0), - (-0.24393069744110107, 1.1662957668304443, 0.0), - (2.9078805141580233e-08, 1.0957571268081665, 0.0), - (2.9078805141580233e-08, 1.2316569089889526, 0.0), - (2.9078805141580233e-08, 1.2316569089889526, 0.0), - (0.1759808510541916, 1.0486031770706177, 0.0), - (0.243930846452713, 1.1662957668304443, 0.0), - (0.243930846452713, 1.1662957668304443, 0.0), - (0.30480796098709106, 0.9197763204574585, 0.0), - (0.4225005805492401, 0.9877263307571411, 0.0), - (0.4225005805492401, 0.9877263307571411, 0.0), - (0.35196200013160706, 0.7437955141067505, 0.0), - (0.487861692905426, 0.74379563331604, 0.0), - (-0.48786139488220215, 0.7437955141067505, 0.0), - (-0.48786139488220215, 4.5077928945147505e-08, 0.0), - (0.48786139488220215, 4.5077928945147505e-08, 0.0), - ) - - def get_custom_shape(self, context: bpy.types.Context) -> object: - """Get the appropriate custom shape based on lock state.""" - obj = context.active_object - if not obj: - return self.custom_shape_closed - - try: - is_open = obj.path_resolve(self.prop_path) - return self.custom_shape_open if is_open else self.custom_shape_closed - except (ValueError, KeyError, AttributeError): - return self.custom_shape_closed - - def setup(self) -> None: - self.custom_shape_closed = self.new_custom_shape("TRIS", self.tris_closed) - self.custom_shape_open = self.new_custom_shape("TRIS", self.tris_open) - - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.get_custom_shape(context)) - - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.get_custom_shape(context), select_id=select_id) +LOCK_TRIS_CLOSED = ( + (-0.3519617021083832, 0.7437955141067505, 0.0), + (-0.3048076927661896, 0.9197763204574585, 0.0), + (-0.4225003123283386, 0.9877263307571411, 0.0), + (-0.4225003123283386, 0.9877263307571411, 0.0), + (-0.3048076927661896, 0.9197763204574585, 0.0), + (-0.1759808510541916, 1.0486031770706177, 0.0), + (-0.24393069744110107, 1.1662957668304443, 0.0), + (-0.1759808510541916, 1.0486031770706177, 0.0), + (2.9078805141580233e-08, 1.0957571268081665, 0.0), + (2.9078805141580233e-08, 1.2316569089889526, 0.0), + (2.9078805141580233e-08, 1.0957571268081665, 0.0), + (0.1759808510541916, 1.0486031770706177, 0.0), + (0.243930846452713, 1.1662957668304443, 0.0), + (0.1759808510541916, 1.0486031770706177, 0.0), + (0.30480796098709106, 0.9197763204574585, 0.0), + (0.4225005805492401, 0.9877263307571411, 0.0), + (0.30480796098709106, 0.9197763204574585, 0.0), + (0.35196200013160706, 0.7437955141067505, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (0.48786139488220215, 4.5077928945147505e-08, 0.0), + (0.48786139488220215, 0.7437955141067505, 0.0), + (-0.3519617021083832, 0.7437955141067505, 0.0), + (-0.4225003123283386, 0.9877263307571411, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (-0.4225003123283386, 0.9877263307571411, 0.0), + (-0.1759808510541916, 1.0486031770706177, 0.0), + (-0.24393069744110107, 1.1662957668304443, 0.0), + (-0.24393069744110107, 1.1662957668304443, 0.0), + (2.9078805141580233e-08, 1.0957571268081665, 0.0), + (2.9078805141580233e-08, 1.2316569089889526, 0.0), + (2.9078805141580233e-08, 1.2316569089889526, 0.0), + (0.1759808510541916, 1.0486031770706177, 0.0), + (0.243930846452713, 1.1662957668304443, 0.0), + (0.243930846452713, 1.1662957668304443, 0.0), + (0.30480796098709106, 0.9197763204574585, 0.0), + (0.4225005805492401, 0.9877263307571411, 0.0), + (0.4225005805492401, 0.9877263307571411, 0.0), + (0.35196200013160706, 0.7437955141067505, 0.0), + (0.487861692905426, 0.74379563331604, 0.0), + (-0.48786139488220215, 0.7437955141067505, 0.0), + (-0.48786139488220215, 4.5077928945147505e-08, 0.0), + (0.48786139488220215, 4.5077928945147505e-08, 0.0), +) -class GizmoArc(bpy.types.Gizmo): - """Arc gizmo for door swing visualization.""" +class GizmoLockOpen(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Static open-padlock glyph.""" + + bl_idname = "VIEW3D_GT_lock_open" + __slots__ = ("custom_shape",) + tris = LOCK_TRIS_OPEN + + +class GizmoLockClosed(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Static closed-padlock glyph.""" + + bl_idname = "VIEW3D_GT_lock_closed" + __slots__ = ("custom_shape",) + tris = LOCK_TRIS_CLOSED + + +ARC_TRIS_DEFAULT = create_circle_arc( + radius=1.0, direction="LEFT", angle_min=DOOR_SWING_ANGLE_MIN, angle_max=DOOR_SWING_ANGLE_MAX +) + + +class GizmoArc(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Static quarter-arc glyph for swing visualisation. + + Consumers needing the mirrored (RIGHT) visual apply a flip-X matrix to + ``matrix_basis``.""" bl_idname = "VIEW3D_GT_arc" + __slots__ = ("custom_shape",) + tris = ARC_TRIS_DEFAULT - __slots__ = ( - "custom_shape_left", - "custom_shape_right", - "prop_path", + +def _fillet_icon_tris() -> tuple[tuple[float, float, float], ...]: + """Filled L-glyph with a smoothly rounded corner — two perpendicular + wall bars joined by a constant-thickness arc band.""" + arc_center_x = 0.0 + arc_center_y = 0.0 + r_outer = 0.28 + r_inner = 0.18 # thickness = 0.10 + arc_segments = 8 + + # Banana sweeps from 270° (downward radial) to 360° = 0° (rightward + # radial). The bars extend the wall material outward from the banana's + # two end caps along the tangent direction. + outer_at_start = (arc_center_x, arc_center_y - r_outer) # 270°, outer + inner_at_start = (arc_center_x, arc_center_y - r_inner) # 270°, inner + outer_at_end = (arc_center_x + r_outer, arc_center_y) # 0°, outer + inner_at_end = (arc_center_x + r_inner, arc_center_y) # 0°, inner + + bar_a_left = -0.45 # horizontal bar extends from banana cap LEFTWARD + bar_b_top = 0.45 # vertical bar extends from banana cap UPWARD + + tris: list[tuple[float, float, float]] = [] + # Horizontal bar: tangent at 270° (downward radial), tangent direction is +X. + # The bar lies along +X with cross-section in radial direction (y). + tris.extend(rect_tris(bar_a_left, outer_at_start[1], outer_at_start[0], inner_at_start[1])) + # Vertical bar: tangent at 0° (rightward radial), tangent direction is +Y. + # The bar lies along +Y with cross-section in radial direction (x). + tris.extend(rect_tris(inner_at_end[0], outer_at_end[1], outer_at_end[0], bar_b_top)) + + # Quarter-banana sector: each angular slice → trapezoid → two CCW triangles. + angle_start = 3.0 * math.pi / 2.0 # 270° + angle_end = 2.0 * math.pi # 360° / 0° + for i in range(arc_segments): + a1 = angle_start + (angle_end - angle_start) * (i / arc_segments) + a2 = angle_start + (angle_end - angle_start) * ((i + 1) / arc_segments) + outer1 = (arc_center_x + r_outer * math.cos(a1), arc_center_y + r_outer * math.sin(a1)) + outer2 = (arc_center_x + r_outer * math.cos(a2), arc_center_y + r_outer * math.sin(a2)) + inner1 = (arc_center_x + r_inner * math.cos(a1), arc_center_y + r_inner * math.sin(a1)) + inner2 = (arc_center_x + r_inner * math.cos(a2), arc_center_y + r_inner * math.sin(a2)) + tris.append((outer1[0], outer1[1], 0.0)) + tris.append((outer2[0], outer2[1], 0.0)) + tris.append((inner2[0], inner2[1], 0.0)) + tris.append((outer1[0], outer1[1], 0.0)) + tris.append((inner2[0], inner2[1], 0.0)) + tris.append((inner1[0], inner1[1], 0.0)) + return tuple(tris) + + +FILLET_TRIS_DEFAULT = _fillet_icon_tris() + + +class GizmoFillet(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Filled fillet glyph for wall-corner rounding.""" + + bl_idname = "VIEW3D_GT_fillet" + __slots__ = ("custom_shape",) + tris = FILLET_TRIS_DEFAULT + # Stacked at ICON_STACK_OFFSET_Y above join in GizmoWallJoinIntersection; + # full-bbox hit overlaps the sibling icons' bboxes and steals their clicks. + hit_uses_bbox = False + + +def _wall_corner_icon_tris() -> tuple[tuple[float, float, float], ...]: + """Filled L-glyph with a sharp 90° inner corner.""" + # Match the fillet icon's bar thickness so the row reads at one visual weight. + outer_y = -0.28 + inner_y = -0.18 + outer_x = 0.28 + inner_x = 0.18 + bar_a_left = -0.45 + bar_b_top = 0.45 + + tris: list[tuple[float, float, float]] = [] + # Bars overlap at the corner square so the L renders as one continuous material. + tris.extend(rect_tris(bar_a_left, outer_y, outer_x, inner_y)) + tris.extend(rect_tris(inner_x, outer_y, outer_x, bar_b_top)) + return tuple(tris) + + +WALL_CORNER_TRIS_DEFAULT = _wall_corner_icon_tris() + + +class GizmoWallCornerIcon(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Filled L-shape glyph (sharp 90° corner) for joining two walls.""" + + bl_idname = "VIEW3D_GT_wall_corner" + __slots__ = ("custom_shape",) + tris = WALL_CORNER_TRIS_DEFAULT + hit_uses_bbox = False # tight stack in GizmoWallJoinIntersection — see GizmoFillet + + +def _wall_tee_icon_tris() -> tuple[tuple[float, float, float], ...]: + """Filled side-T glyph (⊣ orientation) for extending one wall into + another's side. The through wall (vertical bar, right edge) carries a + branching wall (horizontal bar) butting into its midline — visually + distinguishes 'extend wall to wall' from the L-corner 'join' glyph by + *where* the bars meet (middle vs corner).""" + # Match the wall-corner bbox + bar thickness so the icon row reads at + # one visual weight. + bar_lo_y = -0.28 + bar_top = 0.45 + through_inner_x = 0.18 + through_outer_x = 0.28 + branch_left = -0.45 + # Branching bar centered on the through-bar's midline so the vertical + # extends equally above and below — reads as a balanced ⊣. + branch_mid_y = (bar_lo_y + bar_top) / 2 + branch_half_thickness = 0.05 + + tris: list[tuple[float, float, float]] = [] + tris.extend(rect_tris(through_inner_x, bar_lo_y, through_outer_x, bar_top)) + # Branching bar's right edge stops at the through-bar's inner edge so the + # bars touch without overlapping. + tris.extend( + rect_tris( + branch_left, + branch_mid_y - branch_half_thickness, + through_inner_x, + branch_mid_y + branch_half_thickness, + ) ) - - def setup(self) -> None: - """Create arc shapes for LEFT and RIGHT directions.""" - arc_left = create_circle_arc(radius=1.0, direction="LEFT", angle_min=2.0, angle_max=90.0) - arc_right = create_circle_arc(radius=1.0, direction="RIGHT", angle_min=2.0, angle_max=90.0) - - self.custom_shape_left = self.new_custom_shape(type="TRIS", verts=arc_left) - self.custom_shape_right = self.new_custom_shape(type="TRIS", verts=arc_right) - - def _get_shape_for_direction(self, context: bpy.types.Context) -> object: - """Get arc shape based on door swing direction.""" - obj = context.active_object - if not obj: - return self.custom_shape_left - - try: - direction_value = obj.path_resolve(self.prop_path) - if "RIGHT" in str(direction_value): - return self.custom_shape_right - except (ValueError, KeyError, AttributeError): - pass - - return self.custom_shape_left - - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self._get_shape_for_direction(context)) - - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self._get_shape_for_direction(context), select_id=select_id) + return tuple(tris) -class GizmoPen(bpy.types.Gizmo): +WALL_TEE_TRIS_DEFAULT = _wall_tee_icon_tris() + + +class GizmoWallTeeIcon(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Filled T-junction glyph for extending one wall into another's side.""" + + bl_idname = "VIEW3D_GT_wall_tee" + __slots__ = ("custom_shape",) + tris = WALL_TEE_TRIS_DEFAULT + hit_uses_bbox = False # tight stack in GizmoWallJoinIntersection — see GizmoFillet + + +class GizmoPen(StaticTrisGizmoMixin, bpy.types.Gizmo): """Pen/edit icon gizmo for entering edit mode.""" bl_idname = "VIEW3D_GT_pen" @@ -2990,17 +3367,8 @@ class GizmoPen(bpy.types.Gizmo): (0.21042980253696442, 0.321493536233902, 0.0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) - - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - - -class GizmoValidate(bpy.types.Gizmo): +class GizmoValidate(StaticTrisGizmoMixin, bpy.types.Gizmo): """Validate/checkmark icon gizmo for confirming edits.""" bl_idname = "VIEW3D_GT_validate" @@ -3022,17 +3390,8 @@ class GizmoValidate(bpy.types.Gizmo): (0.030080009251832962, -0.1881658434867859, 0.0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) - - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - - -class GizmoCancel(bpy.types.Gizmo): +class GizmoCancel(StaticTrisGizmoMixin, bpy.types.Gizmo): """Cancel/X icon gizmo for canceling edits.""" bl_idname = "VIEW3D_GT_cancel" @@ -3072,17 +3431,8 @@ class GizmoCancel(bpy.types.Gizmo): (0.048707593232393265, 0.0, 0.0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) - - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - - -class GizmoPlus(bpy.types.Gizmo): +class GizmoPlus(StaticTrisGizmoMixin, bpy.types.Gizmo): """Plus icon gizmo for incrementing values.""" bl_idname = "VIEW3D_GT_plus" @@ -3104,17 +3454,8 @@ class GizmoPlus(bpy.types.Gizmo): (0.075, -0.375, 0.0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) - - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - - -class GizmoMinus(bpy.types.Gizmo): +class GizmoMinus(StaticTrisGizmoMixin, bpy.types.Gizmo): """Minus icon gizmo for decrementing values.""" bl_idname = "VIEW3D_GT_minus" @@ -3130,17 +3471,281 @@ class GizmoMinus(bpy.types.Gizmo): (0.375, -0.075, 0.0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) + +class GizmoTrash(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Wastebasket icon for destructive delete actions — body + lid + handle.""" + + bl_idname = "VIEW3D_GT_trash" + + __slots__ = ("custom_shape",) + + # Trash-can profile within the conventional ±0.375 icon bounding box. + # Sized ~15% larger than the baseline 3-rect icon design so the + # destructive button reads as the visual end-stop of the row. Solid + # fills match the Bonsai gizmo-icon convention (Plus / Minus / Cancel). + tris = ( + # Body — slightly narrower than the lid for the classic bin shape. + *rect_tris(-0.23, -0.345, 0.23, 0.207), + # Lid — extends wider on both sides so it sits "over" the body. + *rect_tris(-0.31, 0.207, 0.31, 0.30), + # Handle — small bar centered on top of the lid. + *rect_tris(-0.09, 0.30, 0.09, 0.39), + ) + + +class GizmoArrayParent(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Hierarchy tree glyph — one top node connected to three bottom nodes + by short lines. Fires the operator that selects the parent object of an + array given a child is currently active.""" + + bl_idname = "VIEW3D_GT_array_parent" + + __slots__ = ("custom_shape",) + + # Hierarchy tree: one top node + three bottom nodes wired by trunk + + # crossbar + drop legs. Conventional ±0.375 icon bounding box. + tris = ( + # Top (parent) node. + *rect_tris(-0.075, 0.195, 0.075, 0.345), + # Three child nodes along the bottom row. + *rect_tris(-0.335, -0.335, -0.205, -0.205), + *rect_tris(-0.065, -0.335, 0.065, -0.205), + *rect_tris(0.205, -0.335, 0.335, -0.205), + # Vertical trunk: top node down through the crossbar to the centre child. + *rect_tris(-0.02, -0.205, 0.02, 0.195), + # Horizontal crossbar joining the trunk's midpoint to left/right legs. + *rect_tris(-0.27, -0.07, 0.27, -0.03), + # Drop legs from crossbar to the left and right children. + *rect_tris(-0.29, -0.205, -0.25, -0.07), + *rect_tris(0.25, -0.205, 0.29, -0.07), + ) + + +def _quad_tris(x0: float, y0: float, x1: float, y1: float) -> tuple: + """Two CCW triangles covering rectangle ``(x0,y0)-(x1,y1)`` in Z=0.""" + return ( + (x0, y0, 0.0), (x1, y0, 0.0), (x1, y1, 0.0), + (x0, y0, 0.0), (x1, y1, 0.0), (x0, y1, 0.0), + ) # fmt: skip + + +# 7-segment digit definitions for world-space integer-label gizmos. Each digit's +# strokes fit inside a unit-cell (width 0.22, height 0.40) centred on (0, 0); the +# label builder translates the cell into the final position. Composed of seven +# rectangle "segments" — top, mid, bot horizontals + upper-left/right and +# lower-left/right verticals — so the count gizmo can render any integer 0-9999 +# without an external font. +_DIGIT_STROKES = { + "top": (-0.10, 0.18, 0.10, 0.20), + "mid": (-0.10, -0.02, 0.10, 0.02), + "bot": (-0.10, -0.20, 0.10, -0.18), + "ul": (-0.10, 0.00, -0.07, 0.20), + "ur": (0.07, 0.00, 0.10, 0.20), + "ll": (-0.10, -0.20, -0.07, 0.00), + "lr": (0.07, -0.20, 0.10, 0.00), +} # fmt: skip +_DIGIT_SEGMENTS = { + "0": ("top", "ul", "ur", "ll", "lr", "bot"), + "1": ("ur", "lr"), + "2": ("top", "ur", "mid", "ll", "bot"), + "3": ("top", "ur", "mid", "lr", "bot"), + "4": ("ul", "ur", "mid", "lr"), + "5": ("top", "ul", "mid", "lr", "bot"), + "6": ("top", "ul", "mid", "ll", "lr", "bot"), + "7": ("top", "ur", "lr"), + "8": ("top", "ul", "ur", "mid", "ll", "lr", "bot"), + "9": ("top", "ul", "ur", "mid", "lr", "bot"), +} +# Width of one digit cell including its trailing kerning gap. ``x`` prefix is +# rendered as two crossed diagonals across one cell of the same width. +_DIGIT_CELL_W = 0.26 + + +def _digit_tris(digit: str, cx: float, cy: float) -> tuple: + """Triangles for one ``"0"``..``"9"`` digit centred on ``(cx, cy)``.""" + tris: list[tuple[float, float, float]] = [] + for seg in _DIGIT_SEGMENTS[digit]: + x0, y0, x1, y1 = _DIGIT_STROKES[seg] + tris.extend(_quad_tris(x0 + cx, y0 + cy, x1 + cx, y1 + cy)) + return tuple(tris) + + +def _x_prefix_tris(cx: float, cy: float) -> tuple: + """Triangles for an ``x`` glyph centred on ``(cx, cy)`` — two crossed + diagonals roughly matching a digit's height for the count label.""" + # Each leg is a thin rectangle rotated 45° from the cell centre. Vertex + # coords are precomputed: half-length 0.13 along the rotated axis, half + # width 0.025 perpendicular. Using two quads keeps it TRIS-only. + leg = 0.13 + w = 0.025 + # Leg 1 (top-left → bottom-right). + p1 = (cx - leg - w, cy + leg - w, 0.0) + p2 = (cx - leg + w, cy + leg + w, 0.0) + p3 = (cx + leg + w, cy - leg + w, 0.0) + p4 = (cx + leg - w, cy - leg - w, 0.0) + # Leg 2 (top-right → bottom-left). + q1 = (cx + leg - w, cy + leg + w, 0.0) + q2 = (cx + leg + w, cy + leg - w, 0.0) + q3 = (cx - leg + w, cy - leg - w, 0.0) + q4 = (cx - leg - w, cy - leg + w, 0.0) + return ( + p1, p2, p3, p1, p3, p4, + q1, q2, q3, q1, q3, q4, + ) # fmt: skip + + +def _count_label_tris(count: int, cx: float, cy: float) -> tuple: + """Triangles for an ``xN`` label centred on ``(cx, cy)``. Composes the + ``x`` prefix and each base-10 digit horizontally.""" + digits = str(max(0, int(count))) + total_w = _DIGIT_CELL_W * (1 + len(digits)) + start_x = cx - total_w / 2 + _DIGIT_CELL_W / 2 + tris: list[tuple[float, float, float]] = [] + tris.extend(_x_prefix_tris(start_x, cy)) + for i, d in enumerate(digits): + tris.extend(_digit_tris(d, start_x + (i + 1) * _DIGIT_CELL_W, cy)) + return tuple(tris) + + +class GizmoArrayAll(StaticTrisGizmoMixin, bpy.types.Gizmo): + """2×2 grid of small filled squares — multi-select for an array + (parent + all children). + + On hover from an array child, paints a wireframe bbox around every + sibling in the same array layer.""" + + bl_idname = "VIEW3D_GT_array_all" + + __slots__ = ("custom_shape",) + + # Four small filled squares in a 2x2 grid, each 0.2 wide with a 0.15 gap + # between rows / columns so the grid reads as discrete cells rather than a + # solid block. All within the ±0.375 icon bounding-box convention. + tris = ( + *_quad_tris(-0.275, 0.075, -0.075, 0.275), # top-left + *_quad_tris(0.075, 0.075, 0.275, 0.275), # top-right + *_quad_tris(-0.275, -0.275, -0.075, -0.075), # bottom-left + *_quad_tris(0.075, -0.275, 0.275, -0.075), # bottom-right + ) def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) + super().draw(context) + if self.is_highlight: + self._draw_containing_array_bbox(context) + + def _draw_containing_array_bbox(self, context: bpy.types.Context) -> None: + """Outline every sibling of the active child in the array layer that + produced it. No-op when no resolvable parent / layer.""" + obj = context.active_object + if obj is None: + return + child_element = tool.Ifc.get_entity(obj) + if child_element is None: + return + layer_index = tool.Array.get_child_layer_index(child_element) + if layer_index is None: + return + pset = ifcopenshell.util.element.get_pset(child_element, "BBIM_Array") + if not pset: + return + parent_guid = pset.get("Parent") + if not parent_guid: + return + try: + parent_element = tool.Ifc.get().by_guid(parent_guid) + except RuntimeError: + return + from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox + + draw_array_layer_children_bbox(context, parent_element, layer_index) + + +class GizmoArrayLayerIndicator(bpy.types.Gizmo): + """ARRAY layer entry icon with a world-space ``xN`` count rendered above. + + The 2×2-grid glyph sits in the bottom half of the local frame; the + ``xN`` count is composed of 7-segment digit triangles in the top half. + Both are part of the gizmo's custom shape so the entire glyph is a + single click target. + + On hover, ``draw()`` paints a wireframe bbox around every child of this + layer in the same 3D pass — drawing inline keeps the bbox in lockstep + with the highlight.""" + + bl_idname = "BIM_GT_array_layer_indicator" + + __slots__ = ("custom_shape", "_count", "_built_count", "_layer_index", "_outlined_batch") + + # Icon glyph (2×2 grid) translated down so the upper half stays free for + # the count label. Centred so the gizmo's world anchor falls between the + # icon and the label. + _ICON_TRIS = ( + *_quad_tris(-0.275, -0.475, -0.075, -0.275), + *_quad_tris(0.075, -0.475, 0.275, -0.275), + *_quad_tris(-0.275, -0.225, -0.075, -0.025), + *_quad_tris(0.075, -0.225, 0.275, -0.025), + ) + # Vertical centre of the count label in the gizmo's local frame. + _LABEL_Y = 0.22 + + def setup(self) -> None: + self._count = 0 + self._built_count = -1 + # ``-1`` until the gizmo group calls ``set_layer_index``. The bbox + # highlight no-ops while the index is unassigned. + self._layer_index = -1 + tris = self._build_tris() + self.custom_shape = self.new_custom_shape("TRIS", tris) + self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris}) + self._built_count = 0 + + def set_count(self, count: int) -> None: + self._count = int(count) + + def set_layer_index(self, layer_index: int) -> None: + self._layer_index = int(layer_index) + + def _build_tris(self) -> tuple: + return self._ICON_TRIS + _count_label_tris(self._count, 0.0, self._LABEL_Y) + + def _ensure_shape(self) -> None: + if self._built_count != self._count: + tris = self._build_tris() + self.custom_shape = self.new_custom_shape("TRIS", tris) + self._outlined_batch = batch_for_shader(_get_static_tris_shader(), "TRIS", {"pos": tris}) + self._built_count = self._count + + def draw(self, context: bpy.types.Context) -> None: + self._ensure_shape() + if self.is_highlight: + color = (*self.color_highlight, 1.0) + else: + color = (*self.color, 1.0) + draw_tris_with_outline(self._outlined_batch, self.matrix_basis @ self.matrix_offset, color) + if self.is_highlight: + self._draw_layer_children_bbox(context) def draw_select(self, context: bpy.types.Context, select_id: int) -> None: + self._ensure_shape() self.draw_custom_shape(self.custom_shape, select_id=select_id) + def _draw_layer_children_bbox(self, context: bpy.types.Context) -> None: + """Outline this layer's children inline so the bbox stays in lockstep + with the gizmo highlight.""" + if self._layer_index < 0: + return + obj = context.active_object + if obj is None: + return + parent_element = tool.Ifc.get_entity(obj) + if parent_element is None: + return + from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox -class GizmoMerge(TrisGizmoMixin, bpy.types.Gizmo): + draw_array_layer_children_bbox(context, parent_element, self._layer_index) + + +class GizmoMerge(StaticTrisGizmoMixin, bpy.types.Gizmo): """Two arrows pointing inward toward each other — conveys joining/merging elements.""" bl_idname = "VIEW3D_GT_merge" @@ -3166,7 +3771,7 @@ class GizmoMerge(TrisGizmoMixin, bpy.types.Gizmo): ) -class GizmoSplit(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoSplit(StaticTrisGizmoMixin, bpy.types.Gizmo): """Two arrows pointing outward away from each other — conveys splitting/cutting one element into two. Visual inverse of `GizmoMerge`.""" @@ -3193,7 +3798,33 @@ class GizmoSplit(TrisGizmoMixin, bpy.types.Gizmo): ) -class GizmoExtend(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoUnjoin(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Two C-shaped hooks facing each other across a clear gap — conveys + severing a relationship between two elements (e.g. an + ``IfcRelConnectsPathElements`` between two walls). The "two linked + things pulled apart" silhouette reads as relationship-cut rather than + geometry-cut.""" + + bl_idname = "VIEW3D_GT_unjoin" + + __slots__ = ("custom_shape",) + + # Each hook is three solid bars composing a C: top, bottom, and back + # wall. The two C's face inward across a clear gap so the silhouette + # reads as "two interlocking links pulled apart". + tris = ( + # Left hook — C opening to the right. + *rect_tris(-0.30, 0.11, -0.08, 0.17), + *rect_tris(-0.30, -0.17, -0.08, -0.11), + *rect_tris(-0.30, -0.17, -0.24, 0.17), + # Right hook — mirror, C opening to the left. + *rect_tris(0.08, 0.11, 0.30, 0.17), + *rect_tris(0.08, -0.17, 0.30, -0.11), + *rect_tris(0.24, -0.17, 0.30, 0.17), + ) + + +class GizmoExtend(StaticTrisGizmoMixin, bpy.types.Gizmo): """An arrow pointing into a vertical bar — conveys extending an element to a target line (e.g. extending a wall to the 3D cursor).""" @@ -3215,7 +3846,7 @@ class GizmoExtend(TrisGizmoMixin, bpy.types.Gizmo): ) -class GizmoExtendVertical(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoExtendVertical(StaticTrisGizmoMixin, bpy.types.Gizmo): """Vertical sibling of `GizmoExtend` — arrow pointing UP into a horizontal bar. Conveys extending an element's height to a target Z.""" @@ -3235,7 +3866,7 @@ def _offset_baseline_tris(mark_x: float) -> tuple[tuple[float, float, float], .. return rect_tris(-0.25, -0.07, 0.25, 0.07) + rect_tris(mark_x - 0.04, -0.22, mark_x + 0.04, 0.22) -class GizmoOffsetExterior(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoOffsetExterior(StaticTrisGizmoMixin, bpy.types.Gizmo): """Wall offset baseline indicator — reference axis at the exterior face (left mark).""" bl_idname = "VIEW3D_GT_offset_exterior" @@ -3243,7 +3874,7 @@ class GizmoOffsetExterior(TrisGizmoMixin, bpy.types.Gizmo): tris = _offset_baseline_tris(-0.24) -class GizmoOffsetCenter(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoOffsetCenter(StaticTrisGizmoMixin, bpy.types.Gizmo): """Wall offset baseline indicator — reference axis at the centreline (middle mark).""" bl_idname = "VIEW3D_GT_offset_center" @@ -3251,7 +3882,7 @@ class GizmoOffsetCenter(TrisGizmoMixin, bpy.types.Gizmo): tris = _offset_baseline_tris(0.0) -class GizmoOffsetInterior(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoOffsetInterior(StaticTrisGizmoMixin, bpy.types.Gizmo): """Wall offset baseline indicator — reference axis at the interior face (right mark).""" bl_idname = "VIEW3D_GT_offset_interior" @@ -3259,7 +3890,7 @@ class GizmoOffsetInterior(TrisGizmoMixin, bpy.types.Gizmo): tris = _offset_baseline_tris(0.24) -class GizmoAddOpening(TrisGizmoMixin, bpy.types.Gizmo): +class GizmoAddOpening(StaticTrisGizmoMixin, bpy.types.Gizmo): """A rectangular frame (square outline with a hole in the middle) — conveys adding an opening (window/door/void) to a wall.""" @@ -3372,7 +4003,7 @@ def _generate_circular_arrow_tris() -> tuple[tuple[float, float, float], ...]: return tuple(triangles) -class GizmoCycle(bpy.types.Gizmo): +class GizmoCycle(StaticTrisGizmoMixin, bpy.types.Gizmo): """Circular arrow icon gizmo for cycling through enum values.""" bl_idname = "VIEW3D_GT_cycle" @@ -3381,14 +4012,42 @@ class GizmoCycle(bpy.types.Gizmo): tris = _generate_circular_arrow_tris() - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self.tris) - def draw(self, context: bpy.types.Context) -> None: - self.draw_custom_shape(self.custom_shape) +def _generate_menu_tris() -> tuple[tuple[float, float, float], ...]: + """Three stacked horizontal bars — universal "menu / pick from list" glyph.""" + # Sized ~30% larger than the validate / cancel icon family so the picker + # affordance reads more strongly — picking a type is a higher-stakes click + # than the surrounding edit-mode toggles. + bar_half_thickness = 0.046 + bar_half_width = 0.26 + vertical_spacing = 0.182 + return ( + *rect_tris( + -bar_half_width, + +vertical_spacing - bar_half_thickness, + +bar_half_width, + +vertical_spacing + bar_half_thickness, + ), + *rect_tris(-bar_half_width, -bar_half_thickness, +bar_half_width, +bar_half_thickness), + *rect_tris( + -bar_half_width, + -vertical_spacing - bar_half_thickness, + +bar_half_width, + -vertical_spacing + bar_half_thickness, + ), + ) - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) + +class GizmoMenu(StaticTrisGizmoMixin, bpy.types.Gizmo): + """Hamburger-stack menu icon — 'open a picker to choose from many options'. + + For enums with 5+ values; use ``GizmoCycle`` for 2-4.""" + + bl_idname = "VIEW3D_GT_menu" + + __slots__ = ("custom_shape",) + + tris = _generate_menu_tris() class GizmoArrow(GizmoMovable): @@ -3397,7 +4056,7 @@ class GizmoArrow(GizmoMovable): bl_idname = "BIM_GT_gizmo_arrow" bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) - def _get_arrow_triangles(self) -> tuple[tuple[float, float, float], ...]: + def _get_triangles(self) -> tuple[tuple[float, float, float], ...]: triangles = [] triangles.extend( @@ -3466,16 +4125,10 @@ class GizmoArrow(GizmoMovable): return tuple(triangles) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self._get_arrow_triangles()) - def draw(self, context: bpy.types.Context) -> None: self.draw_custom_shape(self.custom_shape) self.draw_property_tooltip(context) - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - class GizmoArrow2D(GizmoMovable): """Flat 2D arrow that rotates around its axis to face the camera.""" @@ -3488,7 +4141,7 @@ class GizmoArrow2D(GizmoMovable): ARROW_2D_WIDTH = 0.25 ARROW_2D_HEAD_WIDTH = 0.75 - def _get_arrow_2d_triangles(self) -> tuple[tuple[float, float, float], ...]: + def _get_triangles(self) -> tuple[tuple[float, float, float], ...]: """Generate flat arrow geometry in XY plane, pointing along +X.""" shaft = self.ARROW_2D_SHAFT_LENGTH head = self.ARROW_2D_HEAD_LENGTH @@ -3509,16 +4162,10 @@ class GizmoArrow2D(GizmoMovable): (shaft, hw, 0), ) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self._get_arrow_2d_triangles()) - def draw(self, context: bpy.types.Context) -> None: self.draw_custom_shape(self.custom_shape) self.draw_property_tooltip(context) - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - def draw_prepare(self, context: bpy.types.Context) -> None: """Rotate around arrow axis to face camera.""" position = self.matrix_basis.translation @@ -3558,7 +4205,7 @@ class GizmoCone(GizmoMovable): bl_idname = "BIM_GT_gizmo_cone" bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) - def _get_cone_triangles(self) -> tuple[tuple[float, float, float], ...]: + def _get_triangles(self) -> tuple[tuple[float, float, float], ...]: triangles = [] cone_tip_x = CONE_LENGTH @@ -3589,15 +4236,9 @@ class GizmoCone(GizmoMovable): return tuple(triangles) - def setup(self) -> None: - self.custom_shape = self.new_custom_shape("TRIS", self._get_cone_triangles()) - def draw(self, context: bpy.types.Context) -> None: self.draw_custom_shape(self.custom_shape) - def draw_select(self, context: bpy.types.Context, select_id: int) -> None: - self.draw_custom_shape(self.custom_shape, select_id=select_id) - class GizmoDimension(GizmoMovable): """Dimension line gizmo that displays a measurement with extension lines and text. @@ -3659,6 +4300,7 @@ class GizmoDimension(GizmoMovable): "_click_offset", # Offset from dimension tip to click position (for snap correction) "show_extension_lines", # Whether to show extension lines at dimension endpoints "text_formatter", # Optional (props, value) -> str to override the default dimension label + "schematic_attr_name", # Set by BaseSchematicGizmoGroup: attr_name of the bound config, read by hover-highlight ) ARROW_SIZE = 10 @@ -4119,54 +4761,6 @@ class GizmoDimension(GizmoMovable): clear_snap_cache() -class CycleTypeMixin: - """Mixin for operators that cycle through type literals. - - Subclasses must define: - element_checker: Class method name on tool.Blender.Modifier (e.g., "is_door") - props_getter: Method name on tool.Model (e.g., "get_door_props") - type_literal: The type literal from tool.Model (e.g., tool.Model.DoorType) - type_attr: Attribute name on props for the type (e.g., "door_type") - - Optional: - skip_element_check: If True, skip the element type validation (default False) - """ - - element_checker: str - props_getter: str - type_literal: type - type_attr: str - skip_element_check: bool = False - - reverse: bpy.props.BoolProperty(name="Reverse", default=False, options={"HIDDEN", "SKIP_SAVE"}) - - def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[str]: - """Set reverse direction based on Shift key.""" - self.reverse = event.shift - return self.execute(context) - - def _cycle_type(self, context: bpy.types.Context) -> set[str]: - """Common type cycling logic. Call from execute() or _execute().""" - obj = context.active_object - if not obj: - return {"CANCELLED"} - - if not self.skip_element_check: - element = tool.Ifc.get_entity(obj) - checker = getattr(tool.Blender.Modifier, self.element_checker) - if not element or not checker(element): - return {"CANCELLED"} - - props = getattr(tool.Model, self.props_getter)(obj) - types = get_args(self.type_literal) - current = getattr(props, self.type_attr) - idx = types.index(current) if current in types else 0 - direction = -1 if self.reverse else 1 - setattr(props, self.type_attr, types[(idx + direction) % len(types)]) - - return {"FINISHED"} - - class BillboardingGizmoGroupMixin: """Mixin for standalone ``bpy.types.GizmoGroup`` classes whose icons must billboard (face the camera) and re-position every frame. @@ -4299,6 +4893,7 @@ class BaseParametricGizmoGroup: COLOR_RED = (1.0, 0.2, 0.2) COLOR_GREEN = (0.1, 0.8, 0.1) COLOR_BLUE = (0.3, 0.3, 1.0) + COLOR_NEUTRAL = (1.0, 1.0, 1.0) # === Dimension Gizmo Layout (meters) === ARROW_SCALE = 0.25 # Scale factor for arrow gizmos @@ -4317,14 +4912,51 @@ class BaseParametricGizmoGroup: ICON_VALIDATE_X = 0.0 # X position of validate (checkmark) icon ICON_CANCEL_X = 0.5 # X offset from validate for cancel (X) icon ICON_CYCLE_X = 0.87 # X offset from validate for cycle (arrow) icon + # Rightmost local-X used by feature-specific icons (across both idle and + # edit states). Subclasses override when they add icons past the cycle + # slot at 0.87 — currently wall (rotate at 1.24) and stair (minus at + # 1.98). Drives both the ARRAY button position (this class) AND the + # array-layer-icons start position (``GizmoArrayEdition`` runtime lookup), + # so non-colliding features get a tight layout while wall / stair shift + # the array-related slots outward to avoid stomping on the rotate / + # tread-lock / +/- icons. + FEATURE_ICON_MAX_X: float = 0.87 + # Gap between the last feature icon and the ARRAY button (or the first + # array layer icon in idle state). + ICON_ARRAY_GAP: float = 0.37 ICON_Z_OFFSET = 0.5 # Height above element for icons ICON_Y_OFFSET = GIZMO_OFFSET * 2 # Y offset to keep icons clear of geometry + # Offset (meters in world units) used along the screen-up direction when + # world-Z stacking would project to zero on screen (plan / top-down views). + SCREEN_STACK_OFFSET = 0.5 dimension_gizmo_props: list[DimensionGizmoConfig] = [] enable_editing_operator: str = "" finish_editing_operator: str = "" cancel_editing_operator: str = "" + # Mutually exclusive; cycle for 2-4 values, pick for 5+. cycle_type_operator: str = "" + pick_type_operator: str = "" + + REGISTRY: list[type] = [] + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + BaseParametricGizmoGroup.REGISTRY.append(cls) + + @classmethod + def pick_visible_anchor(cls, context: bpy.types.Context, world_base: Vector, world_top: Vector) -> Vector: + """Choose between two anchor candidates so vertical separation stays + visible regardless of view orientation. + + In 3D views the world-Z gap between base and top reads cleanly on + screen, so return ``world_top``. In plan / top-down views that gap + projects to zero and the icons stack on each other; return + ``world_base`` lifted along screen-up by ``SCREEN_STACK_OFFSET`` so + the icons stay individually visible and clickable.""" + if tool.Blender.is_view_top_down(context): + return world_base + tool.Blender.get_screen_up_world(context) * cls.SCREEN_STACK_OFFSET + return world_top @classmethod def get_color_from_name(cls, color: GizmoColor | str) -> tuple[float, float, float]: @@ -4582,22 +5214,13 @@ class BaseParametricGizmoGroup: gizmo_type: str, color: tuple[float, float, float], operator: str, - prop_path: str | None = None, alpha: float = 0.8, **operator_props, ) -> bpy.types.Gizmo: """Create an icon gizmo with common settings. - Args: - gizmo_type: Blender gizmo type (e.g., "VIEW3D_GT_lock", "VIEW3D_GT_plus") - color: RGB color tuple - operator: Operator ID to trigger (e.g., "bim.toggle_stair_property") - prop_path: Optional property path for lock icons (e.g., "BIMStairProperties.lock") - alpha: Opacity (default 0.8) - **operator_props: Additional operator properties to set - - Returns: - The created gizmo + State-aware icons must use a static pair (open/closed) and have the + consumer pick which one to show. """ prefs = tool.Blender.get_addon_preferences() highlight_color = prefs.decorator_color_selected[:3] @@ -4607,8 +5230,6 @@ class BaseParametricGizmoGroup: gz.color = color gz.color_highlight = highlight_color gz.alpha = alpha - if prop_path: - gz.prop_path = prop_path op = gz.target_set_operator(operator) for key, value in operator_props.items(): setattr(op, key, value) @@ -4618,23 +5239,30 @@ class BaseParametricGizmoGroup: self, color: tuple[float, float, float], operator: str, - prop_path: str | None = None, alpha: float = 0.5, **operator_props, ) -> bpy.types.Gizmo: - """Create an arc gizmo for swing/rotation indicators (e.g., door swing). + return self.create_icon_gizmo("VIEW3D_GT_arc", color, operator, alpha, **operator_props) - Args: - color: RGB color tuple - operator: Operator ID to trigger (e.g., "bim.toggle_door_swing") - prop_path: Optional property path (e.g., "BIMDoorProperties.door_type") - alpha: Opacity (default 0.5 for arc gizmos) - **operator_props: Additional operator properties to set + def create_icon_gizmo_lock_pair( + self, + operator: str, + open_color: tuple[float, float, float], + closed_color: tuple[float, float, float] | None = None, + alpha: float = 0.8, + **operator_props, + ) -> tuple[bpy.types.Gizmo, bpy.types.Gizmo]: + """Create an open/closed padlock gizmo pair sharing one operator binding. - Returns: - The created arc gizmo - """ - return self.create_icon_gizmo("VIEW3D_GT_arc", color, operator, prop_path, alpha, **operator_props) + ``closed_color`` defaults to ``open_color`` for neutral pairs. Caller + hides whichever member is inappropriate for the current state, then + positions both together via ``set_icon_gizmo_pair_position`` so a + state flip can't reveal a stale pose.""" + if closed_color is None: + closed_color = open_color + open_gz = self.create_icon_gizmo("VIEW3D_GT_lock_open", open_color, operator, alpha, **operator_props) + closed_gz = self.create_icon_gizmo("VIEW3D_GT_lock_closed", closed_color, operator, alpha, **operator_props) + return open_gz, closed_gz @classmethod def is_element_type(cls, element) -> bool: @@ -4645,12 +5273,39 @@ class BaseParametricGizmoGroup: obj = tool.Blender.get_active_object(is_selected=True) if obj is None: return False - if not tool.Blender.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport: + if not tool.Blender.are_viewport_gizmos_enabled(): return False + if cls.gizmo_pref_name: + prefs = tool.Blender.get_addon_preferences() + feature_prefs = getattr(prefs.gizmos, cls.gizmo_pref_name, None) + if feature_prefs is not None and not getattr(feature_prefs, "enabled", True): + return False if len(tool.Blender.get_selected_objects()) != 1: return False element = tool.Ifc.get_entity(obj) - return bool(element) and cls.is_element_type(element) + if not element: + return False + # Array children are managed replicas — their parametric attributes get + # overwritten on the next ``regenerate_array``, so editing them via the + # parametric gizmos would be silently undone. Skip across every gizmo + # group (door/window/stair/wall/roof/railing/array all inherit this poll). + if tool.Blender.Modifier.is_array_child(element): + return False + if not cls.is_element_type(element): + return False + # Mutual exclusion between parametric and array edit lifecycles — running two + # finish operators against the same object would race, and the doubled + # validate/cancel icon stack reads as a UI bug. Hide this gizmo group + # while a different parametric type is in an active edit lifecycle on obj. + if cls._other_parametric_edit_active(obj): + return False + return True + + @classmethod + def _other_parametric_edit_active(cls, obj: bpy.types.Object) -> bool: + """True if any parametric type OTHER than this group's own is in an + active edit lifecycle on ``obj``.""" + return tool.Parametric.is_object_editing(obj, skip_name=getattr(cls, "gizmo_pref_name", None)) is not None def setup(self, context: bpy.types.Context) -> None: """Template method for gizmo setup. @@ -4714,18 +5369,23 @@ class BaseParametricGizmoGroup: # Subclass should define these class attributes for metadata-driven dispatch # If not defined, subclass must override get_props() and get_gizmo_prefs() - props_getter: str | None = None # e.g., "get_door_props" + props_getter: Callable[[bpy.types.Object], bpy.types.PropertyGroup] | None = None gizmo_pref_name: str | None = None # e.g., "door" def get_props(self, obj: bpy.types.Object) -> Any: """Get properties for the element. Subclass can either: - 1. Define class attribute `props_getter` (e.g., "get_door_props") + 1. Define class attribute `props_getter` (e.g., tool.Model.get_door_props) 2. Override this method directly + + The ``props_getter`` reference is captured at class-definition time + (early binding), so tests cannot redirect it via + ``patch.object(tool.Model, "get_X_props", ...)``. Inject a stub + callable directly when exercising dispatch in tests. """ if self.props_getter: - return getattr(tool.Model, self.props_getter)(obj) + return self.props_getter(obj) raise NotImplementedError("Subclass must define props_getter or override get_props()") def get_addon_prefs(self): @@ -4839,21 +5499,34 @@ class BaseParametricGizmoGroup: y: float, z: float, billboard_rot: Matrix, - scale: float = 0.5, + scale: float = DEFAULT_BILLBOARD_SCALE, ) -> None: - """Set an icon gizmo's position with billboard rotation. - - Args: - gizmo_name: The gizmo attribute name (e.g., "validate_gizmo") - mw: Object's world matrix - x, y, z: Local position coordinates - billboard_rot: Billboard rotation matrix to face camera - scale: Gizmo scale factor (default 0.5) - """ if gz := self.get_gizmo_if_visible(gizmo_name): world_pos = mw @ Vector((x, y, z)) gz.matrix_basis = billboarded_at(world_pos, billboard_rot, scale) + def set_icon_gizmo_pair_position( + self, + open_name: str, + closed_name: str, + mw: Matrix, + x: float, + y: float, + z: float, + billboard_rot: Matrix, + scale: float = DEFAULT_BILLBOARD_SCALE, + ) -> None: + """Position both members of an open/closed pair at the same anchor; + write the matrix on both so a state flip can't reveal a stale pose.""" + open_gz = getattr(self, open_name, None) + closed_gz = getattr(self, closed_name, None) + if not open_gz or not closed_gz: + return + world_pos = mw @ Vector((x, y, z)) + matrix = billboarded_at(world_pos, billboard_rot, scale) + open_gz.matrix_basis = matrix + closed_gz.matrix_basis = matrix + def set_dimension_gizmo_position( self, attr_name: str, @@ -4898,30 +5571,13 @@ class BaseParametricGizmoGroup: else: gizmo.matrix_basis = mw @ base_matrix - def should_hide_dimension_gizmo( - self, gizmo: bpy.types.Gizmo, config: "DimensionGizmoConfig", props, gizmo_prefs - ) -> bool: - """Unified visibility check for dimension gizmos. - - Checks all hide conditions in priority order: - 1. Modal operator hiding - 2. User preference visibility toggle - 3. Editing state - 4. Custom visibility condition from config - - Args: - gizmo: The gizmo to check - config: Dimension gizmo configuration - props: Element properties object - gizmo_prefs: Gizmo visibility preferences - - Returns: - True if gizmo should be hidden, False otherwise - """ + def should_hide_dimension_gizmo(self, gizmo: bpy.types.Gizmo, config: "DimensionGizmoConfig", props) -> bool: + """Hide a dimension gizmo when its modal owner is active, when the + element isn't in edit state for this attribute, or when the config + carries a custom visibility predicate that rejects ``props``. The + per-feature enable toggle is gated upstream by ``poll()``.""" if self.is_gizmo_hidden_by_modal(gizmo): return True - if not getattr(gizmo_prefs, config.attr_name, True): - return True if self.should_hide_gizmo(config.attr_name, props): return True if config.visibility_condition and not config.visibility_condition(props): @@ -4948,9 +5604,20 @@ class BaseParametricGizmoGroup: def setup_editing_gizmos(self, context: bpy.types.Context) -> None: default_color, highlight_color = self.get_decoration_colors() - self.pen_gizmo = self._setup_icon_gizmo( - "VIEW3D_GT_pen", default_color, self.enable_editing_operator, highlight_color - ) + # Pen icon is bound to ``bim.enable_editing_parametric`` (a universal dispatcher) + # rather than the gizmo group's own enable op directly. The dispatcher receives + # this group's ``enable_editing_operator`` as ``feature_enable_op`` and: + # - plain click → fires the per-feature enable (this group's operator) + # - Shift+click → fires ``bim.enable_editing_array`` if the active element is + # an array parent (one pen icon, two behaviours; no second pen needed for arrays). + self.pen_gizmo = self.gizmos.new("VIEW3D_GT_pen") + self.pen_gizmo.use_draw_scale = False + self.pen_gizmo.color = default_color + self.pen_gizmo.color_highlight = highlight_color + self.pen_gizmo.alpha = 0.8 + pen_op = self.pen_gizmo.target_set_operator("bim.enable_editing_parametric") + pen_op.feature_enable_op = self.enable_editing_operator + self.validate_gizmo = self._setup_icon_gizmo( "VIEW3D_GT_validate", self.COLOR_GREEN, self.finish_editing_operator, highlight_color ) @@ -4958,10 +5625,31 @@ class BaseParametricGizmoGroup: "VIEW3D_GT_cancel", self.COLOR_RED, self.cancel_editing_operator, highlight_color ) + # Type-selector slot: cycle (one click advances) or pick (popup menu). + # ``self.cycle_gizmo`` is the shared instance name regardless of icon — + # consumers reposition / hide it via that attribute. ``cycle_type_operator`` + # wins if both are set (consumers shouldn't set both). if self.cycle_type_operator: self.cycle_gizmo = self._setup_icon_gizmo( "VIEW3D_GT_cycle", default_color, self.cycle_type_operator, highlight_color ) + elif self.pick_type_operator: + self.cycle_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_menu", default_color, self.pick_type_operator, highlight_color + ) + + # ARRAY button — visible during the feature edit lifecycle only (positioned by + # ``update_editing_gizmos``). Click commits the current edit and adds a + # Blender-vanilla-defaulted array (count=2, X-offset = bbox extent). The + # array gizmo group opts out via ``hide_array_button = True`` since + # adding an array to an array layer is the panel's job, not a gizmo's. + if not getattr(self, "hide_array_button", False): + self.array_gizmo = self._setup_icon_gizmo( + "VIEW3D_GT_array_all", + default_color, + "bim.add_array_from_feature_edit", + highlight_color, + ) def _make_dimension_getter(self, config: DimensionGizmoConfig): """Create getter closure for dimension gizmo.""" @@ -4987,15 +5675,16 @@ class BaseParametricGizmoGroup: return move_get def _make_dimension_setter(self, config: DimensionGizmoConfig): - """Create setter closure for dimension gizmo.""" + """Setter closure. ``min_value`` clamps only on the default + ``attr_name`` path; a custom ``apply_value`` owns its own bounding.""" if config.apply_value: - apply_fn, min_val = config.apply_value, config.min_value + apply_fn = config.apply_value def move_set(value): obj = bpy.context.active_object if not obj: return - apply_fn(self.get_props(obj), max(min_val, value)) + apply_fn(self.get_props(obj), value) return move_set @@ -5009,12 +5698,78 @@ class BaseParametricGizmoGroup: return move_set + # Fixed visual length (world units) for count gizmos. Decoupled from the + # underlying integer value so a count of 99 doesn't render as a 99-metre bar. + COUNT_GIZMO_VISUAL_LENGTH = 0.3 + + def _make_count_setter(self, config: "CountGizmoConfig"): + """Create setter closure for count gizmo. Snaps to integer step and + clamps to [min_count, max_count] before applying.""" + min_count, max_count, step = config.min_count, config.max_count, config.step + + if config.apply_value: + apply_fn = config.apply_value + + def move_set(value): + obj = bpy.context.active_object + if not obj: + return + snapped = max(min_count, min(max_count, int(round(value / step)) * step)) + apply_fn(self.get_props(obj), snapped) + + return move_set + + attr_name = config.attr_name + + def move_set(value): + obj = bpy.context.active_object + if not obj: + return + snapped = max(min_count, min(max_count, int(round(value / step)) * step)) + setattr(self.get_props(obj), attr_name, snapped) + + return move_set + + def _setup_count_gizmo(self, config: "CountGizmoConfig", highlight_color: tuple[float, float, float]) -> None: + """Configure a BIM_GT_gizmo_dimension instance to behave as an integer stepper. + + Reuses the dimension gizmo type — only configuration differs (no arrows, + no extension lines, int-snapped setter, count_formatter as text_formatter, + fixed visual length applied per-frame in ``update_dimension_gizmos``).""" + gizmo = self.gizmos.new("BIM_GT_gizmo_dimension") + gizmo.move_get_cb = self._make_dimension_getter(config) + gizmo.move_set_cb = self._make_count_setter(config) + gizmo.axis = Vector(config.axis) + gizmo.local_axis = Vector(config.axis) + gizmo.invert_delta = False + gizmo.delta_scale = config.delta_scale + gizmo.prop_name = config.prop_name + gizmo.gizmo_group = self + # Count formatter receives (props, value) like text_formatter; the + # dimension gizmo's draw path calls it once per frame. + gizmo.text_formatter = config.count_formatter or (lambda props, value: str(int(value))) + gizmo.color = self.get_color_from_name(config.color) + gizmo.color_highlight = highlight_color + gizmo.alpha = 1.0 + gizmo.use_draw_modal = True + gizmo.use_draw_scale = False + gizmo.text_offset_sign = 1 + gizmo.text_alignment = TextAlignment.CENTER + # Count visual is a plain bar — no arrows, no extension lines. + gizmo.show_start_arrow = False + gizmo.show_end_arrow = False + gizmo.show_extension_lines = False + setattr(self, f"dimension_{config.attr_name}_gizmo", gizmo) + def setup_dimension_gizmos(self, context: bpy.types.Context) -> None: - """Set up dimension gizmos from dimension_gizmo_props configuration.""" + """Set up value gizmos (dimensions and counts) from dimension_gizmo_props.""" prefs = tool.Blender.get_addon_preferences() highlight_color = prefs.decorator_color_selected[:3] for config in getattr(self, "dimension_gizmo_props", []): + if isinstance(config, CountGizmoConfig): + self._setup_count_gizmo(config, highlight_color) + continue gizmo = self.gizmos.new("BIM_GT_gizmo_dimension") gizmo.move_get_cb = self._make_dimension_getter(config) gizmo.move_set_cb = self._make_dimension_setter(config) @@ -5037,16 +5792,13 @@ class BaseParametricGizmoGroup: setattr(self, f"dimension_{config.attr_name}_gizmo", gizmo) def update_dimension_gizmos(self, mw: Matrix, props) -> None: - """Update dimension gizmos from dimension_gizmo_props configuration.""" - gizmo_prefs = self.get_gizmo_prefs() - + """Update value gizmos (dimensions and counts) from dimension_gizmo_props.""" for config in getattr(self, "dimension_gizmo_props", []): gizmo = getattr(self, f"dimension_{config.attr_name}_gizmo", None) if gizmo is None: continue - # Use unified visibility checker - if self.should_hide_dimension_gizmo(gizmo, config, props, gizmo_prefs): + if self.should_hide_dimension_gizmo(gizmo, config, props): gizmo.hide = True continue @@ -5064,6 +5816,15 @@ class BaseParametricGizmoGroup: else: value = getattr(props, config.attr_name, 0.0) + if isinstance(config, CountGizmoConfig): + # Visual length is decoupled from the integer count — the bar + # stays at a constant world size while the label tracks ``value``. + gizmo.matrix_basis = mw @ base_matrix + gizmo._dimension_length = self.COUNT_GIZMO_VISUAL_LENGTH + gizmo._display_value = value + gizmo.select_bias = -self.COUNT_GIZMO_VISUAL_LENGTH + continue + # Use consolidated negative value handling self._apply_dimension_matrix(gizmo, mw, base_matrix, value) gizmo.show_start_arrow = config.show_start_arrow @@ -5128,7 +5889,7 @@ class BaseParametricGizmoGroup: z=icon_z, billboard_rot=billboard_rot, ) - if self.cycle_type_operator: + if self.cycle_type_operator or self.pick_type_operator: self.cycle_gizmo.hide = self.is_gizmo_hidden_by_modal(self.cycle_gizmo) self.set_icon_gizmo_position( "cycle_gizmo", @@ -5139,15 +5900,45 @@ class BaseParametricGizmoGroup: billboard_rot=billboard_rot, scale=0.30, ) + # ARRAY button sits past the last feature-specific icon. Each + # gizmo group declares its own ``FEATURE_ICON_MAX_X`` (default + # 0.87 past the cycle slot; wall / stair override it) so the + # ARRAY button never lands on top of a rotate / tread-lock icon. + if hasattr(self, "array_gizmo"): + self.array_gizmo.hide = self.is_gizmo_hidden_by_modal(self.array_gizmo) + # 30% smaller than the editing-icon-row default (0.50 → 0.35): + # the array button is a tertiary affordance compared to the + # primary pen / validate / cancel triad, and the smaller + # footprint keeps the edit-mode row from sprawling. + self.set_icon_gizmo_position( + "array_gizmo", + mw=mw, + x=self.ICON_VALIDATE_X + self.FEATURE_ICON_MAX_X + self.ICON_ARRAY_GAP, + y=icon_y, + z=icon_z, + billboard_rot=billboard_rot, + scale=0.35, + ) else: - self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo) - self.set_icon_gizmo_position( - "pen_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot - ) + # ``hide_pen_button = True`` keeps the pen permanently hidden — for + # groups whose edit-mode entry is already provided by another widget + # in the same viewport region. ``GizmoArrayEdition`` opts in because + # its clickable ``xN`` count label (``GizmoArrayCount``) is the + # canonical entry point; surfacing a second pen next to it is the + # redundant icon the user saw in the array gizmo viewport. + if getattr(self, "hide_pen_button", False): + self.pen_gizmo.hide = True + else: + self.pen_gizmo.hide = self.is_gizmo_hidden_by_modal(self.pen_gizmo) + self.set_icon_gizmo_position( + "pen_gizmo", mw=mw, x=self.ICON_VALIDATE_X, y=icon_y, z=icon_z, billboard_rot=billboard_rot + ) self.validate_gizmo.hide = True self.cancel_gizmo.hide = True - if self.cycle_type_operator: + if self.cycle_type_operator or self.pick_type_operator: self.cycle_gizmo.hide = True + if hasattr(self, "array_gizmo"): + self.array_gizmo.hide = True def draw_prepare(self, context: bpy.types.Context) -> None: """Called before drawing - updates gizmos to face camera. @@ -5194,3 +5985,615 @@ class BaseParametricGizmoGroup: mw: Object's world matrix props: Element properties object """ + + +class BaseSchematicGizmoGroup(BaseParametricGizmoGroup): + """Base for parametric gizmo groups that drive a billboarded schematic preview. + + Provides: + + - Schematic-anchored ``BIM_GT_gizmo_dimension`` instances declared via + ``schematic_dimension_props``. Each dimension is laid out in + schematic-local coordinates around the schematic anchor and + billboarded to the camera, so the labelled tag reads the same size + regardless of the bound value and the camera angle. + - A GPU draw handler that renders a live mini preview of the element's + geometry near the icon row. Subclasses build the bmesh in + ``build_schematic_mesh(props)`` and the handler reuses a cached + list of local-coordinate edge pairs across redraws. + + Subclasses leave ``dimension_gizmo_props = []`` (the default here) and + populate ``schematic_dimension_props`` instead. The pen / validate / + cancel / cycle icon row inherited from the parametric base still applies. + + Decoration-only: the preview mesh is not hit-testable; clicks land on + the labelled dimensions, which carry the parametric edit semantics. + """ + + # Schematic groups don't draw in-place dimension lines; the parent's + # setup_dimension_gizmos / update_dimension_gizmos iterate this empty + # list and become no-ops. The schematic equivalents below take their place. + dimension_gizmo_props: list[DimensionGizmoConfig] = [] + + # Declarative dimension configuration consumed by ``setup_schematic_dimensions`` + # and ``update_schematic_dimensions``. Each config produces one + # ``BIM_GT_gizmo_dimension`` instance positioned at a schematic-local + # location and billboarded to the camera. The dimension's *visual* length + # is the actual value rescaled into schematic units via + # ``_compute_schematic_scale`` and floored at a minimum visible length, + # so tiny dimensions stay grabable; the *displayed* numeric label still + # shows the real value via ``text_formatter``. + schematic_dimension_props: list[DimensionGizmoConfig] = [] + + # World-unit half-extent of the schematic decoration box anchored at the + # icon row. Sliders' ``slider_position`` values are interpreted inside + # this box; subclasses scale ``build_schematic_mesh`` output to fit it. + schematic_box_size: float = 0.3 + + # Offset from the icon-row anchor (object origin + element height + + # ICON_Z_OFFSET) to the bottom-centre of the schematic, applied as + # ``billboard_rot @ schematic_anchor_offset``. The coordinate convention + # matches ``billboard_rot``: schematic-local +X → screen RIGHT, +Y → screen + # UP, +Z → toward the viewer. The default ``(0, 0.9, 0)`` lifts the + # schematic by 0.9 world-units in screen UP so it clears the validate / + # cancel icons (which sit at the icon-row anchor with scale 0.2). + schematic_anchor_offset: Vector = Vector((0.0, 0.9, 0.0)) + + # Fixed rotation applied to the schematic frame *before* billboarding, + # so the schematic appears at the same tilt regardless of camera angle. + # Default identity ⇒ flat front view. Subclasses can set a small + # rotation (e.g. ~25° around Y) to expose the depth axis, so dimensions + # along schematic-local Z have a visible on-screen extent. Useful when + # one of the bound properties is a depth/thickness whose true geometric + # direction is otherwise invisible from a flat front-facing schematic. + schematic_view_rotation: "Matrix" = Matrix.Identity(4) + + # Per-concrete-subclass draw-handler singleton. Python writes via + # ``cls._draw_handler_installed = ...`` land on the concrete class + # (not on this base), so two consumer subclasses do not collide. + _draw_handler_installed: object | None = None + + # Per-concrete-subclass cache of (schematic_cache_key → list[(Vector, + # Vector, tag)]) — schematic-local edge endpoints + feature tag, + # pre-computed once per distinct geometry shape (typically per + # ``railing_type``-like enum). The draw handler transforms the cached + # local coords with the current frame's billboard + view rotation + # rather than re-running the bmesh build pipeline; this is the + # standard Blender practice of keeping allocations out of draw + # callbacks. The cache is lazily initialised per subclass via + # ``_get_schematic_geometry_cache`` so concurrent consumers don't + # share entries. + _schematic_geometry_cache: dict | None = None + + # Maps a dimension's ``attr_name`` (e.g. "railing_diameter") to a + # feature tag carried on the schematic mesh's edges (e.g. "rail_tube"). + # When the user hovers a dimension whose ``attr_name`` is in this map, + # all edges tagged with the corresponding feature are drawn in + # ``SCHEMATIC_HIGHLIGHT_COLOR`` so the geometric part being measured + # is visually called out. Subclasses opt in by populating this dict; + # the default empty dict gives no highlight (graceful no-op). + schematic_attr_to_feature: dict[str, str] = {} + + # Per-concrete-subclass cache of the feature tag currently hovered. + # Written by ``_update_hovered_feature`` (instance-side, runs in + # ``draw_prepare``) and read by the class-level draw handler. ``None`` + # means "no dimension hovered" (default-coloured pass only). + _hovered_feature: str | None = None + + # Name of the bmesh edge string layer used to tag edges with a feature + # name. Builders write ``edge[layer] = b"rail_tube"``; the cache reads + # the same layer back on extraction. The string layer is preferred + # over an int layer + lookup table because each builder declares its + # tags in plain Python and the extraction path is symmetric. + SCHEMATIC_FEATURE_LAYER_NAME: str = "schematic_feature" + + # ── Abstract hooks ──────────────────────────────────────────────────── + + @classmethod + def build_schematic_mesh(cls, props) -> "bmesh.types.BMesh": + """Return a transient bmesh of the mini preview in schematic-local coordinates. + + Subclasses MUST implement. The returned bmesh's edges are extracted + into a cached list of local-coord ``(Vector, Vector)`` pairs by + ``_get_schematic_local_edges`` and the bmesh is freed immediately + afterward. The draw handler then transforms the cached pairs per + frame — so the bmesh is built once per distinct + ``schematic_cache_key`` value, not once per draw call. + """ + raise NotImplementedError(f"{cls.__name__} must implement build_schematic_mesh(props) -> bmesh.BMesh") + + @classmethod + def schematic_cache_key(cls, props): + """Hashable key identifying the schematic's geometry shape, or ``None`` to disable caching. + + Subclasses whose schematic depends only on a small set of discrete + (e.g. enum-like) props should return a tuple of those — the bmesh + then rebuilds only when the key changes. Returning ``None`` rebuilds + on every draw, appropriate for schematics whose proportions vary + continuously with the bound properties. + + The cached form lives in ``_schematic_geometry_cache`` and is + camera-independent: only schematic-local edge endpoints are stored, + so the cache survives camera moves and only invalidates on key + change. + """ + return None + + # ── Optional hooks ──────────────────────────────────────────────────── + + def schematic_should_show(self, props) -> bool: + """Whether the schematic preview and sliders should be visible this frame. + + Default: tied to ``props.is_editing``. Subclasses can override to + add additional gating (e.g. hide when a sibling edit mode is open). + """ + return bool(getattr(props, "is_editing", False)) + + # ── Lifecycle (overrides ``BaseParametricGizmoGroup``) ──────────────── + + def setup(self, context: bpy.types.Context) -> None: + self.setup_editing_gizmos(context) + self.setup_schematic_dimensions(context) + self.setup_element_specific_gizmos(context) + + def refresh(self, context: bpy.types.Context) -> None: + if not self.is_setup_complete(): + return + obj = context.active_object + if not obj: + return + props = self.get_props(obj) + mw = obj.matrix_world + self._prime_frame_caches(context, mw) + self.update_editing_gizmos(context, mw, props) + self.update_schematic_dimensions(context, mw, props) + self._reconcile_draw_handler(props) + self._refresh_element_specific(context, mw, props) + self._update_hovered_feature() + + def draw_prepare(self, context: bpy.types.Context) -> None: + if not self.is_setup_complete(): + return + obj = context.active_object + if not obj: + return + props = self.get_props(obj) + mw = obj.matrix_world + self._prime_frame_caches(context, mw) + self.update_editing_gizmos(context, mw, props) + self.update_schematic_dimensions(context, mw, props) + self._reconcile_draw_handler(props) + self._refresh_element_specific(context, mw, props) + self._update_hovered_feature() + + def _update_hovered_feature(self) -> None: + """Record which feature tag the user is currently hovering on. + + Walks the group's gizmos for the first ``is_highlight=True`` + dimension whose ``schematic_attr_name`` maps into + ``schematic_attr_to_feature``, and writes the corresponding tag + onto the concrete class (so the class-level draw handler can + pick it up). ``None`` is written when nothing eligible is + hovered. Cheap walk — runs once per frame, no allocations. + """ + cls = type(self) + attr_to_feature = cls.schematic_attr_to_feature + if not attr_to_feature: + cls._hovered_feature = None + return + for gz in self.gizmos: + if not getattr(gz, "is_highlight", False): + continue + attr_name = getattr(gz, "schematic_attr_name", None) + if attr_name is None: + continue + feature = attr_to_feature.get(attr_name) + if feature is not None: + cls._hovered_feature = feature + return + cls._hovered_feature = None + + # ── Dimension wiring (schematic-anchored ``BIM_GT_gizmo_dimension`` lines) ── + + # Fixed visual length (as a fraction of ``schematic_box_size``) for every + # Schematic dimension bars render as constant-width labelled tags; the + # value reads from the text label, not bar length. Decouples readability + # from value magnitude — a 5mm thickness and a 5m height are equally + # clickable. Drag distance still maps 1:1 to the property's world units. + SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO: float = 0.6 + + def setup_schematic_dimensions(self, context: bpy.types.Context) -> None: + """Create one ``BIM_GT_gizmo_dimension`` per ``DimensionGizmoConfig``.""" + prefs = tool.Blender.get_addon_preferences() + highlight_color = prefs.decorator_color_selected[:3] + + for config in self.schematic_dimension_props: + gizmo = self.gizmos.new("BIM_GT_gizmo_dimension") + gizmo.move_get_cb = self._make_dimension_getter(config) + gizmo.move_set_cb = self._make_dimension_setter(config) + # Non-zero initial axis; per-frame refresh overwrites with the + # billboarded direction. + gizmo.axis = Vector(config.axis) + # No ``local_axis``: schematic drags must follow the billboarded + # bar (screen-up for a vertical bar), not the object-local axis. + gizmo.invert_delta = config.invert_delta + gizmo.delta_scale = config.delta_scale + gizmo.prop_name = config.prop_name + gizmo.gizmo_group = self + gizmo.text_formatter = config.text_formatter + gizmo.color = self.get_color_from_name(config.color) + gizmo.color_highlight = highlight_color + gizmo.alpha = 1.0 + gizmo.use_draw_modal = True + gizmo.use_draw_scale = False + gizmo.text_offset_sign = config.text_offset_sign + gizmo.text_alignment = config.text_alignment + gizmo.show_start_arrow = config.show_start_arrow + gizmo.show_end_arrow = config.show_end_arrow + gizmo.schematic_attr_name = config.attr_name + setattr(self, f"schematic_dim_{config.attr_name}_gizmo", gizmo) + + def update_schematic_dimensions(self, context: bpy.types.Context, mw: Matrix, props) -> None: + """Position and size each schematic-anchored dimension gizmo.""" + billboard_rot = self._frame_billboard_rot + view_rotation = self.schematic_view_rotation + anchor = self._compute_schematic_anchor(props, mw, billboard_rot) + should_show = self.schematic_should_show(props) + default_length = self.schematic_box_size * self.SCHEMATIC_DIM_VISIBLE_LENGTH_RATIO + + for config in self.schematic_dimension_props: + gizmo = getattr(self, f"schematic_dim_{config.attr_name}_gizmo", None) + if gizmo is None: + continue + + if not should_show: + gizmo.hide = True + continue + if config.visibility_condition is not None and not config.visibility_condition(props): + gizmo.hide = True + continue + if self.is_gizmo_hidden_by_modal(gizmo): + gizmo.hide = True + continue + gizmo.hide = False + + # Freeze geometry transforms while a modal is active so an + # orbit-during-drag can't shift the drag direction under the + # user's hand. + if getattr(gizmo, "is_modal", False): + continue + + local_offset = Vector() + if config.matrix_position is not None: + local_offset = Vector(config.matrix_position(props)) + gizmo.matrix_basis = self._schematic_world_matrix( + anchor, billboard_rot, config.axis, local_offset, view_rotation + ) + + # Drag axis = visual bar direction; keep aligned with the on-screen + # bar even when it points partly into screen depth. + gizmo.axis = (billboard_rot @ view_rotation @ Vector(config.axis)).normalized() + + visible_length = ( + config.schematic_visible_length if config.schematic_visible_length is not None else default_length + ) + gizmo.set_dimension_length(visible_length) + gizmo.show_start_arrow = config.show_start_arrow + gizmo.show_end_arrow = config.show_end_arrow + + # ── Schematic anchor + draw handler lifecycle ───────────────────────── + + def _compute_schematic_anchor(self, props, mw: Matrix, billboard_rot: Matrix) -> Vector: + """World-space anchor of the schematic decoration box (instance entry point).""" + return self.compute_schematic_anchor( + mw, + self.get_element_height(props), + self.ICON_VALIDATE_X, + self.ICON_Z_OFFSET, + billboard_rot, + self.schematic_anchor_offset, + ) + + @staticmethod + def compute_schematic_anchor( + mw: Matrix, + element_height: float, + icon_x: float, + icon_z_offset: float, + billboard_rot: Matrix, + schematic_offset: Vector, + ) -> Vector: + """Schematic-anchor world position: icon-row origin + the schematic + offset rotated into the screen frame. + + The anchor itself stays billboard-aligned regardless of + ``schematic_view_rotation``; tilts are applied to the contents + downstream so the anchored frame stays stable on screen.""" + icon_world = mw @ Vector((icon_x, 0.0, element_height + icon_z_offset)) + return icon_world + billboard_rot @ Vector(schematic_offset) + + @staticmethod + def _schematic_world_matrix( + anchor: Vector, + billboard_rot: Matrix, + axis: tuple[float, float, float], + local_position: tuple[float, float, float] | Vector, + view_rotation: Matrix | None = None, + ) -> Matrix: + """``matrix_basis`` for a schematic-anchored gizmo. + + Translates to ``anchor + billboard_rot @ view_rotation @ local_position`` + and rotates +X to the schematic-local ``axis``.""" + if view_rotation is None: + view_rotation = Matrix.Identity(4) + local_offset = view_rotation @ Vector(local_position) + world_pos = anchor + billboard_rot @ local_offset + axis_world = (billboard_rot @ view_rotation @ Vector(axis)).normalized() + x_to_axis = Vector((1, 0, 0)).rotation_difference(axis_world).to_matrix().to_4x4() + return Matrix.Translation(world_pos) @ x_to_axis + + def _reconcile_draw_handler(self, props) -> None: + """Install or remove the GPU draw handler to match ``schematic_should_show``.""" + if self.schematic_should_show(props): + self._install_draw_handler() + else: + self._uninstall_draw_handler() + + @classmethod + def _get_schematic_geometry_cache(cls) -> dict: + """Return the per-concrete-subclass schematic-geometry cache, creating it on first access. + + Subclass attribute writes via ``cls._schematic_geometry_cache = ...`` + land on the concrete class (not on this base), so two consumer + subclasses keep independent caches. The lazy ``__dict__`` check + ensures each subclass starts with its own empty dict rather than + inheriting (and mutating) the base's. + """ + if "_schematic_geometry_cache" not in cls.__dict__ or cls._schematic_geometry_cache is None: + cls._schematic_geometry_cache = {} + return cls._schematic_geometry_cache + + @classmethod + def _get_schematic_local_edges(cls, props) -> "list[tuple[Vector, Vector, str | None]]": + """Return the schematic's edges as schematic-local ``(v0, v1, tag)`` triples. + + ``tag`` is the feature tag stored on the bmesh edge string layer + named by ``SCHEMATIC_FEATURE_LAYER_NAME`` (empty bytes → ``None``). + Builders that don't tag any edges produce all-``None`` tags; the + draw handler then takes the default-only path. + + Hits the per-subclass cache when ``schematic_cache_key(props)`` is + not ``None`` — the bmesh is built only on cache miss. The cached + list contains only local coordinates + tag strings, so it stays + valid across camera moves; the draw handler applies per-frame + transforms (anchor, billboard rotation, view rotation) at render + time. + + Keeping the bmesh allocation off the draw path is the standard + Blender practice — see the ``ProfileDecorator`` pattern, which + likewise caches its shader and rebuilds geometry only on + state-change rather than per draw call. + """ + key = cls.schematic_cache_key(props) + cache = cls._get_schematic_geometry_cache() + if key is not None and key in cache: + return cache[key] + bm = cls.build_schematic_mesh(props) + try: + feat_layer = bm.edges.layers.string.get(cls.SCHEMATIC_FEATURE_LAYER_NAME) + edges: list[tuple[Vector, Vector, str | None]] = [] + for e in bm.edges: + v0 = Vector(e.verts[0].co) + v1 = Vector(e.verts[1].co) + if feat_layer is None: + tag: str | None = None + else: + raw = e[feat_layer] + tag = raw.decode("utf-8") if raw else None + edges.append((v0, v1, tag)) + finally: + bm.free() + if key is not None: + cache[key] = edges + return edges + + @classmethod + def _install_draw_handler(cls) -> None: + """Register a class-level ``POST_VIEW`` handler on ``SpaceView3D``. + + Idempotent. The class attribute write lands on the concrete subclass + (not on this base), so two schematic consumers (railing, roof, …) + keep independent handles. + """ + if cls._draw_handler_installed is not None: + return + cls._draw_handler_installed = bpy.types.SpaceView3D.draw_handler_add( + cls._schematic_draw_callback, (cls,), "WINDOW", "POST_VIEW" + ) + + @classmethod + def _uninstall_draw_handler(cls) -> None: + """Remove the schematic draw handler if installed. Idempotent.""" + if cls._draw_handler_installed is None: + return + bpy.types.SpaceView3D.draw_handler_remove(cls._draw_handler_installed, "WINDOW") + cls._draw_handler_installed = None + + @classmethod + def _props_for_active(cls): + """``(obj, props)`` for the active+selected object, or ``(None, None)``.""" + obj = tool.Blender.get_active_object(is_selected=True) + if obj is None or not cls.props_getter: + return None, None + props = cls.props_getter(obj) + return obj, props + + @classmethod + def _schematic_draw_callback(cls, owner_cls) -> None: + """GPU callback that renders the schematic mesh as wireframe. + + Self-uninstalls when the active object has no editable schematic props. + Per-frame: rebuilds the bmesh from props, transforms verts into the + schematic frame, batches as line segments via ``POLYLINE_UNIFORM_COLOR``. + """ + obj, props = owner_cls._props_for_active() + if obj is None or props is None or not owner_cls.schematic_should_show_class(props): + owner_cls._uninstall_draw_handler() + return + + context = bpy.context + region = getattr(context, "region", None) + rv3d = getattr(context, "region_data", None) + if region is None or rv3d is None: + return + + try: + local_edges = owner_cls._get_schematic_local_edges(props) + except Exception: + # A subclass build that raises would otherwise crash the viewport + # on every redraw. Drop the handler so the user sees a missing + # schematic instead of a broken Blender; the next refresh will + # try again if conditions allow. + owner_cls._uninstall_draw_handler() + return + + if not local_edges: + return + + mw = obj.matrix_world + billboard_rot = get_billboard_rotation(context) + anchor = owner_cls.compute_schematic_anchor( + mw, + owner_cls._get_element_height_class(props), + owner_cls.ICON_VALIDATE_X, + owner_cls.ICON_Z_OFFSET, + billboard_rot, + owner_cls.schematic_anchor_offset, + ) + + view_rotation = owner_cls.schematic_view_rotation + hovered = getattr(owner_cls, "_hovered_feature", None) + default_segments: list[tuple[float, float, float]] = [] + highlight_segments: list[tuple[float, float, float]] = [] + for v0_local, v1_local, tag in local_edges: + a = tuple(anchor + billboard_rot @ view_rotation @ v0_local) + b = tuple(anchor + billboard_rot @ view_rotation @ v1_local) + if hovered is not None and tag == hovered: + highlight_segments.append(a) + highlight_segments.append(b) + else: + default_segments.append(a) + default_segments.append(b) + + shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") + shader.bind() + shader.uniform_float("lineWidth", owner_cls.SCHEMATIC_LINE_WIDTH) + shader.uniform_float("viewportSize", (region.width, region.height)) + if default_segments: + shader.uniform_float("color", owner_cls.SCHEMATIC_LINE_COLOR) + batch_for_shader(shader, "LINES", {"pos": default_segments}).draw(shader) + if highlight_segments: + shader.uniform_float("color", owner_cls.SCHEMATIC_HIGHLIGHT_COLOR) + batch_for_shader(shader, "LINES", {"pos": highlight_segments}).draw(shader) + + @classmethod + def schematic_should_show_class(cls, props) -> bool: + """Class-level visibility gate. Mirror any override of the instance form.""" + return bool(getattr(props, "is_editing", False)) + + @classmethod + def _get_element_height_class(cls, props) -> float: + return getattr(props, "overall_height", getattr(props, "height", 1.0)) + + # ── Visual constants ───────────────────────────────────────────────── + + SCHEMATIC_LINE_COLOR: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 0.85) + # Warm amber, opaque, distinguishable against the default white line + # colour and against most Blender themes. Used to overdraw the subset + # of edges tagged with the hovered dimension's feature. + SCHEMATIC_HIGHLIGHT_COLOR: tuple[float, float, float, float] = (1.0, 0.7, 0.2, 0.95) + SCHEMATIC_LINE_WIDTH: float = 1.5 + + +class BaseIconActionGroup(BillboardingGizmoGroupMixin): + """Base for gizmo groups that emit clickable icon-action gizmos. + + Action gizmos invoke an operator on click and have no associated state — + copy Z rotation, snap to host, align to grid, etc. Each subclass declares + ``action_configs: list[IconActionConfig]`` and one icon is emitted per + config, stacked horizontally and billboarded above the active object's + bounding box. + + Override ``is_eligible_object`` to gate when the group polls in. The + default eligibility is "active object is an IFC element"; subclasses + typically also require a selection cardinality. + + The pen / validate / cancel icon row from ``BaseParametricGizmoGroup`` + polls when **exactly one** object is selected, so action gizmos that + require ``len >= 2`` are mutually exclusive with parametric editing — + there is no icon-row overlap in practice. + """ + + action_configs: ClassVar[list[IconActionConfig]] = [] + + # Layout constants. Icons appear above the active object's bounding box, + # billboarded toward the camera. Tweak per-subclass if a feature needs a + # different anchor. ICON_SCALE matches the validate/cancel cycle scale + # used by BaseParametricGizmoGroup at ICON_VALIDATE_X (0.375 ≈ 75% of + # the default gizmo size) so the action icons sit at the same visual + # weight as the parametric-edit icon row. + ICON_ROW_Z_OFFSET = 0.5 + ICON_SPACING_X = 0.4 + ICON_SCALE = 0.375 + + @classmethod + def is_eligible_object(cls, obj: bpy.types.Object) -> bool: + """Subclass override. Default: any IFC element. + + Subclasses commonly add selection-count or IFC-class filters.""" + return tool.Ifc.get_entity(obj) is not None + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + obj = tool.Blender.get_active_object(is_selected=True) + if obj is None: + return False + if not tool.Blender.are_viewport_gizmos_enabled(): + return False + return cls.is_eligible_object(obj) + + def setup(self, context: bpy.types.Context) -> None: + prefs = tool.Blender.get_addon_preferences() + default_color = tuple(prefs.decorations_colour[:3]) + highlight_color = tuple(prefs.decorator_color_selected[:3]) + for config in self.action_configs: + gizmo = self.setup_icon_gizmo(config.icon, default_color, highlight_color, config.operator) + setattr(self, f"action_{config.name}_gizmo", gizmo) + + def get_icon_anchor(self, context: bpy.types.Context) -> Vector | None: + obj = context.active_object + if obj is None: + return None + z_top = max((c[2] for c in obj.bound_box), default=0.0) + return obj.matrix_world @ Vector((0.0, 0.0, z_top + self.ICON_ROW_Z_OFFSET)) + + def position_gizmos(self, context: bpy.types.Context) -> None: + obj = context.active_object + if obj is None: + return + anchor = self.get_icon_anchor(context) + if anchor is None: + return + billboard_rot = get_billboard_rotation(context) + # World-X spacing keeps a billboarded icon row coherent regardless + # of anchor object rotation. + for i, config in enumerate(self.action_configs): + gizmo = getattr(self, f"action_{config.name}_gizmo", None) + if gizmo is None: + continue + if config.visibility_condition is not None and not config.visibility_condition(obj): + gizmo.hide = True + continue + gizmo.hide = False + pos = anchor + Vector((i * self.ICON_SPACING_X, 0.0, 0.0)) + gizmo.matrix_basis = billboarded_at(pos, billboard_rot, scale=self.ICON_SCALE) From 1961cd905ee2d333f10a14e76d61e8aba5c02fe9 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Thu, 28 May 2026 13:48:15 +0200 Subject: [PATCH 03/14] Fix parametric framework live-session regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle of bugs surfaced when exercising the new gizmo framework end-to-end in a live Blender session after the bim/module/drawing/gizmos.py refactor + TypeAccessor/CycleType/PickType mixins landed. Register / annotation resolution * parametric_lifecycle.py: hoist `entity_instance` import out of TYPE_CHECKING so typing.get_type_hints resolves the Callable[[entity_instance], bool] annotation at operator registration (CycleDoorType, CycleWindowType, CycleStairType failed with NameError). Clarify the INTERFACE return contract on the picker entry-point so readers see why the gizmo step stays off the undo stack. Framework callable contracts * model/wall.py, door.py, window.py, stair.py: migrate `props_getter` and `element_checker` from bl_idname strings to bound classmethods on tool.Model / tool.Parametric. BaseParametricGizmoGroup.get_props expects a callable; the string form raised TypeError on first gizmo poll. * model/door.py, model/stair.py: drop the dead `prop_path=` operator kwarg from create_arc_gizmo / create_icon_gizmo call sites. The framework helper blindly setattrs every kwarg onto the operator's OperatorProperties, but ToggleDoorSwing / ToggleStairProperty don't declare prop_path — the setattr raised mid-setup_element_specific_gizmos, so self.gizmo_door_type / self.lock_gizmo never got assigned and every subsequent draw_prepare tornadoed AttributeError. Nothing reads op.prop_path anywhere; the kwarg was dead data. Dispatcher operators * model/array.py: add EnableEditingParametric (the framework pen-icon dispatcher that routes to a per-feature edit operator by bl_idname string) and AddArrayFromFeatureEdit (binds the framework's array icon to bim.add_array on the current parametric draft). * model/__init__.py: register both new operators. Per-frame robustness * drawing/gizmos.py: guard BaseParametricGizmoGroup.draw_prepare with is_setup_complete() — matches the existing guard in refresh() and in BaseSchematicGizmoGroup.draw_prepare(). Defense-in-depth: when any subclass's setup raises mid-way, draw_prepare now no-ops cleanly instead of per-frame AttributeError-tornadoing on whatever attribute the failed setup phase was meant to populate. * model/decorator.py: guard ProfileDecorator.__call__ against context.active_object is None. The decorator is a per-frame viewport draw handler; deselecting or deleting the active object while it's installed crashed on obj.mode access. Treat None the same as "no longer in edit mode" — uninstall + fire the exit callback if present. * geometry/data.py: ViewportData.load() populates `data` before flipping `is_loaded`, so a raise from cls.mode() no longer leaves the class flag-set but data-empty for subsequent reads. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/gizmos.py | 2 + src/bonsai/bonsai/bim/module/geometry/data.py | 6 +- .../bonsai/bim/module/model/__init__.py | 2 + src/bonsai/bonsai/bim/module/model/array.py | 126 ++++++++++++++++++ .../bonsai/bim/module/model/decorator.py | 2 +- src/bonsai/bonsai/bim/module/model/door.py | 8 +- src/bonsai/bonsai/bim/module/model/stair.py | 6 +- src/bonsai/bonsai/bim/module/model/wall.py | 2 +- src/bonsai/bonsai/bim/module/model/window.py | 6 +- src/bonsai/bonsai/bim/parametric_lifecycle.py | 18 ++- 10 files changed, 156 insertions(+), 22 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 55096bc6a0..519451d47b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -5948,6 +5948,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 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/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 26dca1984d..84e4c7f886 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, 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..149d91b68b 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() 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/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..104e521082 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -1829,7 +1829,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 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/parametric_lifecycle.py b/src/bonsai/bonsai/bim/parametric_lifecycle.py index 6fa74ab81d..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, get_args +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. @@ -500,9 +498,15 @@ class PickTypeMixin(TypeAccessorBase): op.value = v context.window_manager.popup_menu(draw, title=self.bl_label, icon="MENU_PANEL") - # INTERFACE (not FINISHED) keeps the menu-opening invocation out of the - # undo stack; the picked-value write below returns FINISHED, so the - # type change remains undoable as a single step. + # 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]: From d7b5ac1453d3115ac0c78fd092eb7e77fe0652a0 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Thu, 28 May 2026 14:06:11 +0200 Subject: [PATCH 04/14] Add wall draft-resync helper + wire 6 mutation operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a one-shot wall IFC mutation (unjoin / split / merge / extend / join-at-corner …) the always-visible gizmos on the OTHER side of the join can be left reading stale ``BIMWallProperties`` — the IFC geometry moved but the draft props that drive the gizmo handles still point at the pre-mutation numbers, so a subsequent edit-mode enter shows the wall at its old length / position. * New ``_maybe_resync_wall_props_from_ifc(obj)``: re-primes a single wall's draft props from current IFC, with guards for non-walls, non-parametric walls, and walls in an active draft session (the draft is then the source of truth, not IFC). Must run from an operator ``_execute`` — ID writes from gizmo refresh raise. * New ``_resync_walls_after_mutation(objs)``: iterates the above across a selection. * Six existing mutation operators gain a resync call after their ``core.*`` / ``DumbWallJoiner`` mutation completes: UnjoinWalls, ExtendWallsToUnderside, ExtendWallsToWall, SplitWall, MergeWall, JoinWallsIntersection. MergeWall resyncs only the surviving wall — the active wall is the deletion target. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 36 +++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 104e521082..034def1b1e 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 @@ -167,6 +168,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 _validate_wall_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 +208,7 @@ 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 ExtendWallsToUnderside(bpy.types.Operator, tool.Ifc.Operator): @@ -212,6 +238,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 +280,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 +483,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 +512,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"} @@ -2680,4 +2713,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"} From 70845e4dd451372fad8faf193a2003fe3b57be6b Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Thu, 21 May 2026 22:59:00 +0200 Subject: [PATCH 05/14] Add wall path-connection inverse-walk helpers The single-wall unjoin gizmo needs to enumerate every IfcRelConnectsPathElements a wall participates in, regardless of which side of the rel the wall was authored on, and place an icon at each join's physical location. Two helpers carry that work: _path_connection_location_world wraps core.compute_path_connection_location at the Vector boundary. _iter_path_connections walks ConnectedTo + ConnectedFrom, normalises orientation to (other, self_ct, other_ct), and filters non-wall partners + None refs so per-frame gizmo positioning survives malformed IFC. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 54 +++++++++ .../test/bim/module/model/test_wall_gizmos.py | 109 ++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 034def1b1e..8643c354c4 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -2402,6 +2402,60 @@ 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 + + class GizmoWallAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin): """Activates when a wall (active) and one non-wall blender object are co-selected. 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")] From 6c21e2b6f4cc338b3e521eb277445a84164ad943 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Thu, 28 May 2026 15:05:23 +0200 Subject: [PATCH 06/14] Add single-wall unjoin operator + gizmo group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GizmoWallJoinIntersection's unjoin only fires when exactly two walls are selected and surfaces one icon at their shared corner — useless when the wall has 3+ joins and the user wants to disconnect just one. * UnjoinWallPathConnection: surgical counterpart to UnjoinWalls. Disconnects the active wall from a single partner wall identified by IFC GlobalId (invariant under Blender-object renames + file save/reload + undo). Walks both inverse arrays of the active wall for the specific IfcRelConnectsPathElements joining the pair — matches DumbWallJoiner.split's pattern and avoids disconnect_path's direction-sensitivity. Resyncs both walls' draft props after the recreate_wall pass. * GizmoWallUnjoinSingle: activates on exactly-one selected LAYER2 wall. Preallocates a pool of 16 unjoin icons (Blender forbids gizmo allocation outside setup(); ATSTART + ATEND + ATPATH rels are rarely more than a handful). Per-frame, iterates _iter_path_connections, positions one billboarded icon at each join via tool.Wall.path_connection_location_world, and hides the rest. Each visible icon's bound operator carries the partner GlobalId, so a click removes only that one rel. * model/__init__.py: register both classes alphabetically. Mutually exclusive with GizmoWallJoinIntersection via poll() — that group requires len(selected) == 2; this one requires 1. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/__init__.py | 2 + src/bonsai/bonsai/bim/module/model/wall.py | 184 ++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 84e4c7f886..80c36c3e2f 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -94,6 +94,7 @@ classes = ( wall.GizmoWallEdition, wall.GizmoWallExtendVertically, wall.GizmoWallJoinIntersection, + wall.GizmoWallUnjoinSingle, wall.JoinWallsIntersection, wall.MergeWall, wall.OffsetWalls, @@ -102,6 +103,7 @@ classes = ( wall.SplitWall, wall.SplitWallAtCursor, wall.ToggleWallOpenings, + wall.UnjoinWallPathConnection, wall.UnjoinWalls, opening.AddBoolean, opening.CloneOpening, diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 8643c354c4..cabeae384c 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -211,6 +211,80 @@ class UnjoinWalls(bpy.types.Operator, tool.Ifc.Operator): _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): bl_idname = "bim.extend_walls_to_underside" bl_label = "Extend Walls To Underside" @@ -2747,6 +2821,116 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin 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 tool.Blender.are_viewport_gizmos_enabled(): + 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 JoinWallsIntersection(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.join_walls_intersection" bl_label = "Join Walls at Corner" From 6874d52100cbf585f499f6ceff8a4b50bc06d996 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Thu, 28 May 2026 15:14:51 +0200 Subject: [PATCH 07/14] Add cursor-aware extend-arrow flip on wall edit gizmos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extend-X / extend-Z icons in GizmoWallEdition's cursor row are billboarded toward the camera; without orientation polish they always point in the same screen-space direction regardless of which wall endpoint the click will move (or whether the cursor sits above or below the wall top). New helper mirrors the icon's local-X (extend-X) or local-Y (extend-Z) axis so each arrow points toward the end it will move: * Extend-X: walk wall midpoint to figure out which endpoint stays fixed (cursor past midpoint → ATSTART stays; cursor before midpoint → ATEND stays). Project the fixed endpoint into screen-space and flip the arrow when the gizmo's anchor sits on the same side. * Extend-Z: flip when the cursor is below the wall top (within EXTEND_FLIP_EPSILON tolerance). Called once per resolved cursor gizmo from ``GizmoWallEdition._update_cursor_gizmos``, after the gizmo's ``matrix_basis`` is set by ``gizmo.billboarded_at``. Reuses ``gizmo.should_flip_extend_arrow`` + ``EXTEND_FLIP_MIRROR_X/Y`` + ``EXTEND_FLIP_EPSILON`` already on tool. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index cabeae384c..63358b4eb7 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -2133,6 +2133,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. @@ -2195,6 +2196,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. From c250b2c1a70d97fa288a4f29aa9bafbbf69a738b Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Thu, 28 May 2026 15:30:46 +0200 Subject: [PATCH 08/14] Gate parametric-edit array gizmo until integration completes The framework's parametric-edit icon row currently binds an array icon to bim.add_array_from_feature_edit, but the supporting per- feature add-array flow and gizmo positioning haven't fully landed. Showing the icon today lets the user click it and trigger a half- wired flow. Force the icon hidden inside the props.is_editing branch of BaseParametricGizmoGroup.update_editing_gizmos. The else-branch (not editing) already hides it, so this just mirrors that behavior during edit mode. Drop this gate when array integration completes to re-enable the icon position + visibility plumbing. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/gizmos.py | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 519451d47b..f025df38a4 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -5900,25 +5900,14 @@ class BaseParametricGizmoGroup: billboard_rot=billboard_rot, scale=0.30, ) - # ARRAY button sits past the last feature-specific icon. Each - # gizmo group declares its own ``FEATURE_ICON_MAX_X`` (default - # 0.87 past the cycle slot; wall / stair override it) so the - # ARRAY button never lands on top of a rotate / tread-lock icon. + # 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 = self.is_gizmo_hidden_by_modal(self.array_gizmo) - # 30% smaller than the editing-icon-row default (0.50 → 0.35): - # the array button is a tertiary affordance compared to the - # primary pen / validate / cancel triad, and the smaller - # footprint keeps the edit-mode row from sprawling. - self.set_icon_gizmo_position( - "array_gizmo", - mw=mw, - x=self.ICON_VALIDATE_X + self.FEATURE_ICON_MAX_X + self.ICON_ARRAY_GAP, - y=icon_y, - z=icon_z, - billboard_rot=billboard_rot, - scale=0.35, - ) + self.array_gizmo.hide = True else: # ``hide_pen_button = True`` keeps the pen permanently hidden — for # groups whose edit-mode entry is already provided by another widget From 49348908e6cbd979517fe46ac1466ee117c28443 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Thu, 28 May 2026 16:42:42 +0200 Subject: [PATCH 09/14] Add wall-fillet helper functions + recreate_wall hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven module-level helpers in wall.py that the upcoming wall-fillet operators + gizmo groups depend on. Each is self-contained or references only helpers earlier in the file; the operators and gizmos themselves land in follow-up commits. * _wall_fillet_props / _wall_fillet_preview_active / _wall_fillet_preview_walls: thin read-side accessors over the BIMPreviewProperties.wall_fillet pointer (added with the operators commit). Safe today: get_preview_props returns None until the pointer is attached. * _walls_have_zero_slope_for_fillet: validates that input walls are vertical (x_angle ~ 0); slanted-extrusion fillets require swept-along-curve geometry the banana profile builder doesn't support. * _build_curved_corner_body_representation: builds the banana (annular sector) IfcExtrudedAreaSolid as a polyline-tessellated IfcIndexedPolyCurve. * _apply_fillet_corner_geometry: positions the corner wall at tangent_a and rebuilds its body. Shared by the creation operator and the regenerate path. * _resolve_two_walls: pulls (active, other) from a 2-wall selection, validates both as LAYER2 + straight-axis + not-already- a-fillet-corner. * _pick_dominant_wall_material: returns the thickest layer's material from an element's IfcMaterialLayerSet / Usage. * regenerate_fillet_corner_wall: re-runs the geometry build from BBIM_Wall.FilletRadius + current neighbour layer parameters. Called by tool.Model.recreate_wall when the IsFilletCorner pset is set; the FIXME(PR4) placeholder in recreate_wall is dropped. * _wall_fillet_gizmo_x_matrix: 4x4 placement matrix with local +X aligned to a world-space direction; used by the fillet preview gizmo group. Centralises the IsFilletCorner pset read as tool.Parametric.is_fillet_corner_wall — replaces 3 inline get_pset(element, "BBIM_Wall", "IsFilletCorner") sites (tool.Model.recreate_wall, tool.Model.recalculate_walls, tool.Parametric.is_path_connectable_wall) plus the new _resolve_two_walls call. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 328 +++++++++++++++++++++ src/bonsai/bonsai/tool/model.py | 20 +- src/bonsai/bonsai/tool/parametric.py | 7 + 3 files changed, 350 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 63358b4eb7..bda794547c 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -54,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 @@ -2557,6 +2558,333 @@ def _iter_path_connections( 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) + + +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. 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")) From 2114c1d5d03dd238dc4d67c6ba8fffd4e980ea8e Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Sat, 30 May 2026 13:04:49 +0200 Subject: [PATCH 10/14] Add wall-fillet feature: operators, gizmos, decorator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end fillet flow on top of the helpers + recreate_wall hook (landed in the previous commit). Users select two LAYER2 walls, click the fillet entry icon, drag the live radius widget, and validate to replace the corner with a curved LAYER2 corner wall (banana body). Operators (5): * EnableWallFilletPreview: 2-wall selection → validates LAYER2 + straight axis + zero-slope + intersect-or-joined state → seeds the preview props with a default radius computed from the shorter available leg. * FinishWallFilletPreview: dispatches CreateWallFillet with the tuned radius; clears preview state on FINISHED, preserves it on failure so the user can re-tune without re-selecting. * CancelWallFilletPreview: clears preview state, no IFC mutation. * EnableWallFilletPreviewFromCorner: pen-icon re-edit on an existing fillet corner — pre-fills the preview from the corner's BBIM_Wall pset + walks the inverse graph to recover wall A and wall B. * CreateWallFillet: deletes any prior corner + A↔B path connection, shortens A and B to the tangent points, instantiates a corner wall from A's type, unassigns the swept-layer material/type (the explicit banana body MUST own its geometry), assigns the dominant material, rebuilds the body, sets a straight 2-point chord axis, stores BBIM_Wall.IsFilletCorner+FilletRadius, reconnects A and B to the corner with NOTDEFINED on the corner's side. Gizmo groups (2 new + entry icon on existing): * GizmoWallFilletPreview: visible while a preview is active. Bundles a radius_dim widget at the arc apex, a trim_dim widget along wall A expressing the same DOF via the leg setback distance (trim = |radius| * tan(sweep/2)), and validate / cancel icons anchored above the apex in screen-up. * GizmoWallFilletReedit: pen-icon entry on an existing fillet corner wall (single-selection, BBIM_Wall.IsFilletCorner set, both neighbour connections present). Mutually exclusive with an active preview. * GizmoWallJoinIntersection now stacks a fillet entry icon (VIEW3D_GT_fillet → bim.enable_wall_fillet_preview) above the existing join/unjoin icon in the joined and intersect state branches. Property + decorator infrastructure: * prop.py: BIMWallFilletPreviewProperties (Scene-level draft) + BIMPreviewProperties umbrella with only the wall_fillet pointer. The umbrella is the seam preview_base.py (landed in PR3) already reads via getattr(scene, "BIMPreviewProperties", None). * decorator.py: _stroke_lines_alpha helper + WallFilletPreviewDecorator. Polls is_active; renders leg projections + arc + arc-center construction lines from tool.Wall.compute_wall_fillet_geometry. * __init__.py: registers operators + gizmo groups + property groups + wires Scene.BIMPreviewProperties. * handler.py: WallFilletPreviewDecorator.install/uninstall in _install_decorators — always installed, self-polls on is_active. Drive-by: extract gizmo.get_screen_up(billboard_rot) helper — the local +Y of a billboard rotation is the camera's screen-up world direction. Replaces 4 inline `billboard_rot @ Vector((0.0, 1.0, 0.0))` sites added across the fillet feature's gizmo groups. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/handler.py | 6 + .../bonsai/bim/module/drawing/gizmos.py | 8 + .../bonsai/bim/module/model/__init__.py | 11 + .../bonsai/bim/module/model/decorator.py | 148 ++++ src/bonsai/bonsai/bim/module/model/prop.py | 62 ++ src/bonsai/bonsai/bim/module/model/wall.py | 798 +++++++++++++++++- 6 files changed, 1032 insertions(+), 1 deletion(-) 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/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index f025df38a4..c50828b324 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -1674,6 +1674,14 @@ def billboarded_at(world_pos: Vector, billboard_rot: Matrix, scale: float = DEFA 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 diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 80c36c3e2f..df41bb8ccc 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -93,6 +93,8 @@ classes = ( wall.GizmoWallAddOpening, wall.GizmoWallEdition, wall.GizmoWallExtendVertically, + wall.GizmoWallFilletPreview, + wall.GizmoWallFilletReedit, wall.GizmoWallJoinIntersection, wall.GizmoWallUnjoinSingle, wall.JoinWallsIntersection, @@ -105,6 +107,11 @@ classes = ( wall.ToggleWallOpenings, wall.UnjoinWallPathConnection, wall.UnjoinWalls, + wall.EnableWallFilletPreview, + wall.FinishWallFilletPreview, + wall.CancelWallFilletPreview, + wall.EnableWallFilletPreviewFromCorner, + wall.CreateWallFillet, opening.AddBoolean, opening.CloneOpening, opening.EditOpenings, @@ -165,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, @@ -295,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) @@ -313,6 +323,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/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 149d91b68b..c2cdab4512 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -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/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/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index bda794547c..0f460a5f27 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -62,6 +62,11 @@ 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 regenerate_wall_mesh_from_props(obj: bpy.types.Object) -> None: """Rebuild ``obj.data`` as a preview box from ``BIMWallProperties`` without touching IFC. @@ -2851,6 +2856,434 @@ def regenerate_fillet_corner_wall(element: ifcopenshell.entity_instance, obj: bp _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.""" @@ -3072,6 +3505,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] @@ -3084,9 +3522,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(): @@ -3118,6 +3562,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 @@ -3129,6 +3579,7 @@ 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 @@ -3172,6 +3623,13 @@ 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 @@ -3286,6 +3744,344 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix 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 tool.Blender.are_viewport_gizmos_enabled(): + return False + if _wall_fillet_preview_active(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 + if not tool.Parametric.is_fillet_corner_wall(element): + return False + # Both neighbour connections must still exist for the re-edit to + # recover the original corner. + has_a = any(r.is_a("IfcRelConnectsPathElements") for r in getattr(element, "ConnectedFrom", [])) + has_b = any(r.is_a("IfcRelConnectsPathElements") for r in getattr(element, "ConnectedTo", [])) + return has_a and has_b + + 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" From 788d4fe8e86f0c38e104e4b7acf10294d4f7d147 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Sun, 31 May 2026 10:38:30 +0200 Subject: [PATCH 11/14] Hide sister gizmos during preview + ESC cancels + DRY wall polls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three live-session regressions surfaced after the fillet feature landed. Sister gizmos competed with the active preview: * preview_base.any_preview_active(context): new helper iterates the PREVIEW_CANCEL_OPS registry and returns True if any preview is open. Future previews registered there automatically gate sister gizmos. * BaseParametricGizmoGroup.poll (gizmos.py): short-circuits on any_preview_active so every parametric gizmo (door/window/stair/ roof/railing/wall edition) hides during ANY preview. * The 4 wall gizmo groups with explicit polls (GizmoWallAddOpening, GizmoWallExtendVertically, GizmoWallJoinIntersection, GizmoWallUnjoinSingle) + GizmoWallFilletReedit gain the same gate. DRY: extract _wall_gizmo_poll_gate(context): * 5 wall gizmo polls each duplicated the 2 pre-flight checks (viewport-gizmos enabled + no preview active). The helper centralises them — each poll becomes a single short-circuit line followed by its per-feature selection inspection. ESC cancels the active preview: * try_cancel_active_preview already existed in preview_base since PR3 but had no caller. Hooked into OverrideEscape.execute (geometry/ operator.py) as a new elif branch — same keymap that already cancels pen gizmo edit mode + item mode + edit mode + aggregate mode. Order in the branch chain matters: try preview cancel before falling back to try_canceling_editing_modifier_parameters_or_path so the in- flight preview wins over a stale modifier-edit cancel attempt. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/gizmos.py | 8 ++++++ .../bonsai/bim/module/geometry/operator.py | 3 ++ .../bonsai/bim/module/model/preview_base.py | 11 ++++++++ src/bonsai/bonsai/bim/module/model/wall.py | 28 ++++++++++++------- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index c50828b324..071ede7ac3 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -5283,6 +5283,14 @@ class BaseParametricGizmoGroup: return False 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) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index e7fa918d50..890e75e7b9 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/preview_base.py b/src/bonsai/bonsai/bim/module/model/preview_base.py index e99aadc2f4..b58e6ea246 100644 --- a/src/bonsai/bonsai/bim/module/model/preview_base.py +++ b/src/bonsai/bonsai/bim/module/model/preview_base.py @@ -74,6 +74,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/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 0f460a5f27..62898a9023 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -67,6 +67,19 @@ _FILLET_DEFAULT_LEG_FRACTION = 0.25 # Quarter of the shorter available leg — _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. @@ -3336,8 +3349,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: @@ -3405,8 +3417,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: @@ -3493,8 +3504,7 @@ class GizmoWallJoinIntersection(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: @@ -3664,7 +3674,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix @classmethod def poll(cls, context: bpy.types.Context) -> bool: - if not tool.Blender.are_viewport_gizmos_enabled(): + if not _wall_gizmo_poll_gate(context): return False active = tool.Blender.get_active_object(is_selected=True) if active is None: @@ -4032,9 +4042,7 @@ class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix @classmethod def poll(cls, context: bpy.types.Context) -> bool: - if not tool.Blender.are_viewport_gizmos_enabled(): - return False - if _wall_fillet_preview_active(context): + if not _wall_gizmo_poll_gate(context): return False active = tool.Blender.get_active_object(is_selected=True) if active is None: From 7e5e7b8d6a3660102385655cceb685efbf692b6f Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Sun, 31 May 2026 12:15:39 +0200 Subject: [PATCH 12/14] Drop wall.py local read_geometry + validate dupes + relax gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cohesive cleanups in one commit. A. Migrate wall.py to PR3-absorbed tool methods (fixes bug 4: pen icon missing on fillet corner walls): PR3 shipped tool.Wall.read_geometry + tool.Wall.validate_for_parametric_edit but wall.py kept local duplicates predating that work. The local _read_wall_geometry guards on tool.Blender.Modifier.is_wall (LAYER2-only) while the tool method guards on tool.Parametric.is_path_connectable_wall (LAYER2 OR fillet corner). Consequence: _get_wall_geom_cached → local _read_wall_geometry returned None for every fillet corner → GizmoWallFilletReedit.position_gizmos hit `if geom is None: hide` → pen icon was unreachable for every fillet corner the user created. Three _read_wall_geometry callers migrated to tool.Wall.read_geometry (_read_wall_state_into_props, _get_wall_geom_cached, GizmoWallJoinIntersection.position_gizmos). Two _validate_wall_for_parametric_edit callers migrated to tool.Wall.validate_for_parametric_edit (_maybe_resync_wall_props_from_ifc, EnableEditingWall._execute). Local helpers deleted; docstring references updated. B. Drop over-restrictive gizmo gates (fixes bug 1: join icons missing when walls intersect away from endpoints): GizmoWallJoinIntersection.position_gizmos no longer hides itself when the projected intersection lands further than MAX_DISTANCE_TO_ENDPOINT_ FACTOR (0.75 wall lengths) from any endpoint. The remaining PARALLEL_DOT_THRESHOLD (cos 2°) gate via project_axis_intersection returns None for near-parallel walls and is the only correctness bound; distance from endpoints is a UI concern, not a geometric one. GizmoWallFilletReedit.poll drops the has_a / has_b ConnectedFrom + ConnectedTo guard — the IsFilletCorner pset is the authoritative signal. EnableWallFilletPreviewFromCorner.execute already separately validates both neighbour connections and reports a user-facing error if either side is disconnected. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 101 +++++---------------- 1 file changed, 21 insertions(+), 80 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 62898a9023..a0658574ff 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -142,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"] @@ -195,7 +173,7 @@ def _maybe_resync_wall_props_from_ifc(obj: "bpy.types.Object | None") -> None: No-op during a draft session; the draft is then the source of truth.""" if obj is None: return - if _validate_wall_for_parametric_edit(obj) is not None: + if tool.Wall.validate_for_parametric_edit(obj) is not None: return props = tool.Model.get_wall_props(obj) if props.is_editing: @@ -1750,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"} @@ -2390,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 @@ -2440,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. @@ -2462,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] @@ -3493,12 +3446,6 @@ 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 @@ -3592,8 +3539,13 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin 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])), @@ -3603,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. @@ -3624,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: @@ -4053,13 +3995,12 @@ class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix element = tool.Ifc.get_entity(active) if element is None or not element.is_a("IfcWall"): return False - if not tool.Parametric.is_fillet_corner_wall(element): - return False - # Both neighbour connections must still exist for the re-edit to - # recover the original corner. - has_a = any(r.is_a("IfcRelConnectsPathElements") for r in getattr(element, "ConnectedFrom", [])) - has_b = any(r.is_a("IfcRelConnectsPathElements") for r in getattr(element, "ConnectedTo", [])) - return has_a and has_b + # 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() From ee63137c6c0c47690561be5c979244a6e04a104a Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Sun, 31 May 2026 19:10:48 +0200 Subject: [PATCH 13/14] Discard previews on IFC save + harden preview-active gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save-path: * SaveProject._execute (project/operator.py) now calls preview_base.discard_pending_previews(context.scene) right after tool.Parametric.commit_pending_edits(). Previews are session- transient — discard rather than commit. Sibling gizmo polls gate on each preview's is_active flag; a stuck flag persisted through the save would silently hide them on reload. Mirrors the pattern already in gizmos-8088. Preview-active gate hardening: * preview_base.get_preview_props tolerates contexts without a ``scene`` attribute. Pre-existing tests use SimpleNamespace mocks for the context; the previous getattr(context.scene, ...) raised AttributeError before the inner default kicked in. Test update: * test_wall_header_refresh.test_geom_generation_invalidates_wall_geom_cache patches tool.Wall.read_geometry instead of the now-deleted local wall._read_wall_geometry (commit 7e5e7b8d6 migrated the call site). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/preview_base.py | 8 ++++++-- src/bonsai/bonsai/bim/module/project/operator.py | 5 +++++ .../test/bim/module/model/test_wall_header_refresh.py | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/preview_base.py b/src/bonsai/bonsai/bim/module/model/preview_base.py index b58e6ea246..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 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/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). From 453e6dc1cc1a3242566ba626bc788cb56761ed9d Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 1 Jun 2026 08:35:26 +0200 Subject: [PATCH 14/14] Add behaviour-contract tests for PR4 surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three test files covering PR4's new surfaces — preview registry, wall-gizmo poll behaviour, fillet operator registration. Every test walks the live registry or class hierarchy instead of hard-coding preview keys, operator names, or helper function names, so adding a new preview / wall gizmo group / fillet operator exercises the same invariants without test edits. test_preview_base.py (6 tests): * RegistryContract: every PREVIEW_CANCEL_OPS entry resolves to a callable cancel operator on bpy.ops.bim. * GetPreviewPropsTolerance: get_preview_props returns None for contexts without a scene (regression guard for the SimpleNamespace bug fixed in commit ee63137c6). * ActivationCycle (registry-driven loop): any_preview_active toggles with each registered preview's is_active flag; discard_pending_previews clears every active flag across every registered preview. * SaveOnDiscardWired: locates the bim.save_project operator dynamically and verifies its execute path references the discard helper by its actual __name__. test_wall_gizmo_poll_gate.py (4 tests): * WallGizmoGroupsHideDuringPreview: walks the wall module for bpy.types.GizmoGroup subclasses (skips preview-owner exceptions whose bl_idname contains 'preview'), mocks any_preview_active to True, and asserts every discovered gizmo's poll returns False. * BaseParametricGizmoPollHidesDuringPreview: mirrors the test for the cross-feature parametric framework base class. test_fillet_operators.py (3 tests): * FilletOperatorsRegistered: at-least-four-ops + every-discovered-op- is-callable. Catches accidental deregistration. * EnableRejectsIneligibleSelection: poll returns False without a selection so the operator is greyed-out in menus. State-clearing tests via bpy.ops.bim.cancel_wall_fillet_preview() are deliberately omitted — the operator early-returns when context.screen is unattached and prior tests in the model lane can leave the screen in that state, making the dispatch path inherently flaky. Live testing covers the behaviour. Net: 13 tests pass cleanly in both single-file and full model lane. Generated with the assistance of an AI coding tool. --- .../bim/module/model/test_fillet_operators.py | 96 ++++++++++ .../bim/module/model/test_preview_base.py | 178 ++++++++++++++++++ .../module/model/test_wall_gizmo_poll_gate.py | 154 +++++++++++++++ 3 files changed, 428 insertions(+) create mode 100644 src/bonsai/test/bim/module/model/test_fillet_operators.py create mode 100644 src/bonsai/test/bim/module/model/test_preview_base.py create mode 100644 src/bonsai/test/bim/module/model/test_wall_gizmo_poll_gate.py 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)