From 87bca20df733e22894fab59ad706053a41fd445d Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Fri, 5 Jun 2026 12:45:51 +0200 Subject: [PATCH] Relocate feature decorators to their owning modules Three feature-specific decorators previously lived in bim/module/model/decorator.py despite owning state only their home module reads: * ArrayPreviewDecorator + ArraySelectionHighlightDecorator + draw_array_layer_children_bbox -> array.py (read array edit-state props and walk BBIM_Array psets) * WallGizmoPreviewDecorator + draw_wall_partner_bbox -> wall.py (dereference wall.py-private classes and helpers via lazy imports) decorator.py keeps cross-cutting infrastructure (BoundingBoxDecorator, SlabDirectionDecorator, WallAxisDecorator, WallFilletPreviewDecorator, PolylineDecorator, ProductDecorator) and the shared bbox primitives (bbox_world_edges, draw_polyline_segments, _BBOX_EDGES, _stroke_lines_alpha, _fill_quads_alpha) that several feature files now import. handler.py and gizmos.py update their import paths; the wall-feature lazy imports inside WallGizmoPreviewDecorator methods collapse to direct references now that the decorator lives in wall.py. No behaviour change. Wall lane 37/37, array lane 15/15, wall forward-compat 6/6, parametric-registry 8/8 still pass. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/handler.py | 8 +- .../bonsai/bim/module/drawing/gizmos.py | 4 +- src/bonsai/bonsai/bim/module/model/array.py | 264 ++++++++ .../bonsai/bim/module/model/decorator.py | 641 +----------------- src/bonsai/bonsai/bim/module/model/wall.py | 365 +++++++++- 5 files changed, 634 insertions(+), 648 deletions(-) diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index f33e4af68e..8a62dfbdf4 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -42,17 +42,19 @@ from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock from bonsai.bim.module.aggregate.decorator import AggregateDecorator from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator from bonsai.bim.module.model import wall_offset_gizmos -from bonsai.bim.module.model.data import AuthoringData -from bonsai.bim.module.model.decorator import ( +from bonsai.bim.module.model.array import ( ArrayPreviewDecorator, ArraySelectionHighlightDecorator, +) +from bonsai.bim.module.model.data import AuthoringData +from bonsai.bim.module.model.decorator import ( BoundingBoxDecorator, SlabDirectionDecorator, WallAxisDecorator, WallFilletPreviewDecorator, - WallGizmoPreviewDecorator, ) from bonsai.bim.module.model.preview_base import discard_pending_previews +from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator from bonsai.bim.module.nest.decorator import NestDecorator cwd = os.path.dirname(os.path.realpath(__file__)) diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 6b2ff925bf..638e0f6990 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -3792,7 +3792,7 @@ class GizmoArrayAll(StaticTrisGizmoMixin, bpy.types.Gizmo): parent_element = tool.Ifc.get().by_guid(parent_guid) except RuntimeError: return - from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox + from bonsai.bim.module.model.array import draw_array_layer_children_bbox draw_array_layer_children_bbox(context, parent_element, layer_index) @@ -3877,7 +3877,7 @@ class GizmoArrayLayerIndicator(bpy.types.Gizmo): 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 + from bonsai.bim.module.model.array import draw_array_layer_children_bbox draw_array_layer_children_bbox(context, parent_element, self._layer_index) diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py index b9bc726802..f4a97b0f21 100644 --- a/src/bonsai/bonsai/bim/module/model/array.py +++ b/src/bonsai/bonsai/bim/module/model/array.py @@ -28,12 +28,20 @@ from mathutils import Matrix, Vector import bonsai.bim.module.drawing.gizmos as gizmo import bonsai.tool as tool +from bonsai.bim.decorator_cache import TokenCache from bonsai.bim.module.drawing.gizmos import ( COLOR_GREEN, COLOR_RED, DimensionGizmoConfig, IconSlot, ) +from bonsai.bim.module.model.decorator import ( + _BBOX_EDGES, + _BBOX_HIGHLIGHT_LINE_ALPHA, + _BBOX_HIGHLIGHT_LINE_WIDTH, + bbox_world_edges, + draw_polyline_segments, +) from bonsai.bim.parametric_lifecycle import ( IntegerInputDialogMixin, ParametricEditMixinBase, @@ -1410,3 +1418,259 @@ class GizmoArrayChild(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin): gz = getattr(self, name) world_pos = mw @ Vector((x, 0, bbox_top + self.ICON_Z_OFFSET)) gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, self.ICON_SCALE) + + +_ARRAY_LAYER_BBOX_MAX_CHILDREN = 200 + + +def draw_array_layer_children_bbox( + context: bpy.types.Context, + parent_element: ifcopenshell.entity_instance, + layer_index: int, + max_children: int = _ARRAY_LAYER_BBOX_MAX_CHILDREN, +) -> None: + """Paint a wireframe bbox around every child of one array layer in the + same 3D pass. Called inline from gizmo ``draw()`` methods so the highlight + tracks the hover cursor one-for-one — no POST_VIEW handler, no timing lag. + + Total: silently no-ops on missing pset, unparseable JSON, out-of-range + layer index, unresolvable child GUIDs, or empty child geometry.""" + if layer_index < 0: + return + data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data") + if not data_text: + return + try: + layers = json.loads(data_text) + except (ValueError, TypeError): + return + if layer_index >= len(layers): + return + child_guids = layers[layer_index].get("children", []) + if not child_guids: + return + ifc_file = tool.Ifc.get() + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + for guid in child_guids[:max_children]: + try: + child_element = ifc_file.by_guid(guid) + except RuntimeError: + continue + child_obj = tool.Ifc.get_object(child_element) + if child_obj is None: + continue + segments.extend(bbox_world_edges(child_obj)) + if not segments: + return + prefs = tool.Blender.get_addon_preferences() + color = prefs.decorator_color_special[:3] + draw_polyline_segments( + context, + segments, + color, + _BBOX_HIGHLIGHT_LINE_ALPHA, + _BBOX_HIGHLIGHT_LINE_WIDTH, + ) + + +class ArrayPreviewDecorator(tool.Blender.ViewportDecorator): + """Faint bbox wireframe at each future array instance during the edit lifecycle. + Pure GPU preview gated on the array's draft props — no IFC mutation.""" + + LINE_WIDTH = 1.2 + LINE_ALPHA = 0.45 + MAX_PREVIEW_INSTANCES = 200 + + def draw(self, context: bpy.types.Context) -> None: + if not tool.Blender.are_viewport_gizmos_enabled(): + return + prefs = tool.Blender.get_addon_preferences() + obj = context.active_object + if obj is None or not obj.bound_box: + return + element = tool.Ifc.get_entity(obj) + if not element or not tool.Parametric.is_array(element): + return + props = tool.Model.get_array_props(obj) + if not props.is_editing: + return + count = int(props.count) + if count <= 1 or count > self.MAX_PREVIEW_INSTANCES: + return + + segments = self._compute_segments(obj, props, count) + if not segments: + return + + color = prefs.decorator_color_selected[:3] + draw_polyline_segments(context, segments, color, self.LINE_ALPHA, self.LINE_WIDTH) + + def _compute_segments( + self, + parent_obj: bpy.types.Object, + props, + count: int, + ) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]: + """World-space (start, end) line segments for the bbox edges of + every future instance (i = 1 … count-1; i = 0 is the parent itself). + props.x/y/z are SI — the edit-lifecycle Enable hydrates them via + si_conversion, so no unit_scale multiplier here.""" + offset = Vector((props.x, props.y, props.z)) + if props.method == "DISTRIBUTE": + divider = (count - 1) if count > 1 else 1 + offset = offset / divider + + parent_mw = parent_obj.matrix_world + parent_corners = [Vector(c) for c in parent_obj.bound_box] + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + for i in range(1, count): + delta = offset * i + child_mw = parent_mw.copy() + if props.use_local_space: + child_mw.translation = parent_mw @ delta + else: + child_mw.translation = parent_mw.translation + delta + world_corners = [child_mw @ corner for corner in parent_corners] + for a, b in _BBOX_EDGES: + segments.append((tuple(world_corners[a]), tuple(world_corners[b]))) + return segments + + +class ArraySelectionHighlightDecorator(tool.Blender.ViewportDecorator): + """Bounding-box overlay surfacing the array family of the selected object. + + Two activation modes: + + - **Child selected** — parent drawn in the addon's *special* + decorator color (bright accent); other siblings in the *unselected* + color at lower alpha so the parent stands out. The selected child + itself keeps Blender's standard selection outline. + - **Parent selected** (idle, not editing) — every existing child drawn + in the *unselected* color at lower alpha. The parent is already + visually flagged by Blender's selection outline. Suppressed during + an active array edit lifecycle so the live preview wireframes don't + double-draw with the existing-children overlay.""" + + LINE_WIDTH = 1.5 + PARENT_ALPHA = 0.7 + SIBLING_ALPHA = 0.35 + MAX_SIBLINGS = 200 + + def __init__(self) -> None: + self._family_cache: TokenCache = TokenCache() + + def draw(self, context: bpy.types.Context) -> None: + if not tool.Blender.are_viewport_gizmos_enabled(): + return + prefs = tool.Blender.get_addon_preferences() + obj = context.active_object + if obj is None: + return + if not obj.select_get(): + return + element = tool.Ifc.get_entity(obj) + if not element: + return + + if tool.Blender.Modifier.is_array_child(element): + self._draw_for_child(context, prefs, element, obj) + elif tool.Parametric.is_array(element): + props = tool.Model.get_array_props(obj) + if not props.is_editing: + self._draw_for_parent(context, prefs, element, obj) + + def _draw_for_child(self, context, prefs, element, obj): + family = self._resolve_family_for_child(obj, element) + if family is None: + return + parent_obj, sibling_objs = family + + parent_segments = bbox_world_edges(parent_obj) + if parent_segments: + draw_polyline_segments( + context, + parent_segments, + prefs.decorator_color_special[:3], + self.PARENT_ALPHA, + self.LINE_WIDTH, + ) + self._draw_siblings(context, prefs, sibling_objs) + + def _draw_for_parent(self, context, prefs, element, obj): + child_objs = self._resolve_children_for_parent(obj, element) + self._draw_siblings(context, prefs, child_objs) + + def _resolve_family_for_child(self, obj, element): + return self._family_cache.get_or_compute( + ("child", obj.session_uid, element.id()), + lambda: self._collect_family_from_child(element, obj), + ) + + def _resolve_children_for_parent(self, obj, element): + return ( + self._family_cache.get_or_compute( + ("parent", obj.session_uid, element.id()), + lambda: self._collect_children(element, exclude=obj), + ) + or [] + ) + + def _draw_siblings(self, context, prefs, sibling_objs): + if not sibling_objs: + return + if len(sibling_objs) > self.MAX_SIBLINGS: + sibling_objs = sibling_objs[: self.MAX_SIBLINGS] + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] + for sib_obj in sibling_objs: + segments.extend(bbox_world_edges(sib_obj)) + draw_polyline_segments( + context, + segments, + prefs.decorator_color_unselected[:3], + self.SIBLING_ALPHA, + self.LINE_WIDTH, + ) + + def _collect_family_from_child(self, element, obj): + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset: + return None + parent_guid = pset.get("Parent") + if not parent_guid: + return None + try: + parent_element = tool.Ifc.get().by_guid(parent_guid) + except RuntimeError: + return None + parent_obj = tool.Ifc.get_object(parent_element) + if not parent_obj: + return None + siblings = self._collect_children(parent_element, exclude=obj, also_exclude=parent_obj) + return parent_obj, siblings + + def _collect_children(self, parent_element, exclude=None, also_exclude=None): + parent_data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data") + if not parent_data_text: + return [] + try: + layers = json.loads(parent_data_text) + except (ValueError, TypeError): + return [] + children: list[bpy.types.Object] = [] + seen_ids: set[int] = set() + if exclude is not None: + seen_ids.add(id(exclude)) + if also_exclude is not None: + seen_ids.add(id(also_exclude)) + for layer in layers: + for child_guid in layer.get("children", []): + try: + child_element = tool.Ifc.get().by_guid(child_guid) + except RuntimeError: + continue + child_obj = tool.Ifc.get_object(child_element) + if child_obj is None or id(child_obj) in seen_ids: + continue + seen_ids.add(id(child_obj)) + children.append(child_obj) + return children diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 621814d6c0..81d2b2f1d5 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -18,10 +18,9 @@ from __future__ import annotations -import json import math from math import cos, pi, radians, sin, tan -from typing import Any, Literal, Optional +from typing import Any, Literal import blf import bmesh @@ -42,7 +41,6 @@ from mathutils import Matrix, Quaternion, Vector import bonsai.core.geometry import bonsai.tool as tool -from bonsai.bim.decorator_cache import TokenCache from bonsai.bim.module.drawing.helper import format_distance @@ -2276,640 +2274,3 @@ def draw_polyline_segments( _BBOX_HIGHLIGHT_LINE_WIDTH = 1.8 _BBOX_HIGHLIGHT_LINE_ALPHA = 0.8 -_ARRAY_LAYER_BBOX_MAX_CHILDREN = 200 - - -def draw_array_layer_children_bbox( - context: bpy.types.Context, - parent_element: ifcopenshell.entity_instance, - layer_index: int, - max_children: int = _ARRAY_LAYER_BBOX_MAX_CHILDREN, -) -> None: - """Paint a wireframe bbox around every child of one array layer in the - same 3D pass. Called inline from gizmo ``draw()`` methods so the highlight - tracks the hover cursor one-for-one — no POST_VIEW handler, no timing lag. - - Total: silently no-ops on missing pset, unparseable JSON, out-of-range - layer index, unresolvable child GUIDs, or empty child geometry.""" - if layer_index < 0: - return - data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data") - if not data_text: - return - try: - layers = json.loads(data_text) - except (ValueError, TypeError): - return - if layer_index >= len(layers): - return - child_guids = layers[layer_index].get("children", []) - if not child_guids: - return - ifc_file = tool.Ifc.get() - segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] - for guid in child_guids[:max_children]: - try: - child_element = ifc_file.by_guid(guid) - except RuntimeError: - continue - child_obj = tool.Ifc.get_object(child_element) - if child_obj is None: - continue - segments.extend(bbox_world_edges(child_obj)) - if not segments: - return - prefs = tool.Blender.get_addon_preferences() - color = prefs.decorator_color_special[:3] - draw_polyline_segments( - context, - segments, - color, - _BBOX_HIGHLIGHT_LINE_ALPHA, - _BBOX_HIGHLIGHT_LINE_WIDTH, - ) - - -def draw_wall_partner_bbox( - context: bpy.types.Context, - partner_obj: bpy.types.Object, -) -> None: - """Paint a wireframe bbox around ``partner_obj`` in the same 3D pass. - Called inline from gizmo ``draw()`` methods so the highlight tracks the - hover cursor one-for-one — no POST_VIEW handler, no timing lag. - - Silently no-ops if the object has no bounding box (e.g. Empties).""" - segments = bbox_world_edges(partner_obj) - if not segments: - return - prefs = tool.Blender.get_addon_preferences() - color = prefs.decorator_color_special[:3] - draw_polyline_segments( - context, - segments, - color, - _BBOX_HIGHLIGHT_LINE_ALPHA, - _BBOX_HIGHLIGHT_LINE_WIDTH, - ) - - -class ArrayPreviewDecorator(tool.Blender.ViewportDecorator): - """Faint bbox wireframe at each future array instance during the edit lifecycle. - Pure GPU preview gated on the array's draft props — no IFC mutation.""" - - LINE_WIDTH = 1.2 - LINE_ALPHA = 0.45 - MAX_PREVIEW_INSTANCES = 200 - - def draw(self, context: bpy.types.Context) -> None: - if not tool.Blender.are_viewport_gizmos_enabled(): - return - prefs = tool.Blender.get_addon_preferences() - obj = context.active_object - if obj is None or not obj.bound_box: - return - element = tool.Ifc.get_entity(obj) - if not element or not tool.Parametric.is_array(element): - return - props = tool.Model.get_array_props(obj) - if not props.is_editing: - return - count = int(props.count) - if count <= 1 or count > self.MAX_PREVIEW_INSTANCES: - return - - segments = self._compute_segments(obj, props, count) - if not segments: - return - - color = prefs.decorator_color_selected[:3] - draw_polyline_segments(context, segments, color, self.LINE_ALPHA, self.LINE_WIDTH) - - def _compute_segments( - self, - parent_obj: bpy.types.Object, - props, - count: int, - ) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]: - """World-space (start, end) line segments for the bbox edges of - every future instance (i = 1 … count-1; i = 0 is the parent itself). - props.x/y/z are SI — the edit-lifecycle Enable hydrates them via - si_conversion, so no unit_scale multiplier here.""" - offset = Vector((props.x, props.y, props.z)) - if props.method == "DISTRIBUTE": - divider = (count - 1) if count > 1 else 1 - offset = offset / divider - - parent_mw = parent_obj.matrix_world - parent_corners = [Vector(c) for c in parent_obj.bound_box] - segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] - for i in range(1, count): - delta = offset * i - child_mw = parent_mw.copy() - if props.use_local_space: - child_mw.translation = parent_mw @ delta - else: - child_mw.translation = parent_mw.translation + delta - world_corners = [child_mw @ corner for corner in parent_corners] - for a, b in _BBOX_EDGES: - segments.append((tuple(world_corners[a]), tuple(world_corners[b]))) - return segments - - -class ArraySelectionHighlightDecorator(tool.Blender.ViewportDecorator): - """Bounding-box overlay surfacing the array family of the selected object. - - Two activation modes: - - - **Child selected** — parent drawn in the addon's *special* - decorator color (bright accent); other siblings in the *unselected* - color at lower alpha so the parent stands out. The selected child - itself keeps Blender's standard selection outline. - - **Parent selected** (idle, not editing) — every existing child drawn - in the *unselected* color at lower alpha. The parent is already - visually flagged by Blender's selection outline. Suppressed during - an active array edit lifecycle so the live preview wireframes don't - double-draw with the existing-children overlay.""" - - LINE_WIDTH = 1.5 - PARENT_ALPHA = 0.7 - SIBLING_ALPHA = 0.35 - MAX_SIBLINGS = 200 - - def __init__(self) -> None: - self._family_cache: TokenCache = TokenCache() - - def draw(self, context: bpy.types.Context) -> None: - if not tool.Blender.are_viewport_gizmos_enabled(): - return - prefs = tool.Blender.get_addon_preferences() - obj = context.active_object - if obj is None: - return - if not obj.select_get(): - return - element = tool.Ifc.get_entity(obj) - if not element: - return - - if tool.Blender.Modifier.is_array_child(element): - self._draw_for_child(context, prefs, element, obj) - elif tool.Parametric.is_array(element): - props = tool.Model.get_array_props(obj) - if not props.is_editing: - self._draw_for_parent(context, prefs, element, obj) - - def _draw_for_child(self, context, prefs, element, obj): - family = self._resolve_family_for_child(obj, element) - if family is None: - return - parent_obj, sibling_objs = family - - parent_segments = bbox_world_edges(parent_obj) - if parent_segments: - draw_polyline_segments( - context, - parent_segments, - prefs.decorator_color_special[:3], - self.PARENT_ALPHA, - self.LINE_WIDTH, - ) - self._draw_siblings(context, prefs, sibling_objs) - - def _draw_for_parent(self, context, prefs, element, obj): - child_objs = self._resolve_children_for_parent(obj, element) - self._draw_siblings(context, prefs, child_objs) - - def _resolve_family_for_child(self, obj, element): - return self._family_cache.get_or_compute( - ("child", obj.session_uid, element.id()), - lambda: self._collect_family_from_child(element, obj), - ) - - def _resolve_children_for_parent(self, obj, element): - return ( - self._family_cache.get_or_compute( - ("parent", obj.session_uid, element.id()), - lambda: self._collect_children(element, exclude=obj), - ) - or [] - ) - - def _draw_siblings(self, context, prefs, sibling_objs): - if not sibling_objs: - return - if len(sibling_objs) > self.MAX_SIBLINGS: - sibling_objs = sibling_objs[: self.MAX_SIBLINGS] - segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = [] - for sib_obj in sibling_objs: - segments.extend(bbox_world_edges(sib_obj)) - draw_polyline_segments( - context, - segments, - prefs.decorator_color_unselected[:3], - self.SIBLING_ALPHA, - self.LINE_WIDTH, - ) - - def _collect_family_from_child(self, element, obj): - pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") - if not pset: - return None - parent_guid = pset.get("Parent") - if not parent_guid: - return None - try: - parent_element = tool.Ifc.get().by_guid(parent_guid) - except RuntimeError: - return None - parent_obj = tool.Ifc.get_object(parent_element) - if not parent_obj: - return None - siblings = self._collect_children(parent_element, exclude=obj, also_exclude=parent_obj) - return parent_obj, siblings - - def _collect_children(self, parent_element, exclude=None, also_exclude=None): - parent_data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data") - if not parent_data_text: - return [] - try: - layers = json.loads(parent_data_text) - except (ValueError, TypeError): - return [] - children: list[bpy.types.Object] = [] - seen_ids: set[int] = set() - if exclude is not None: - seen_ids.add(id(exclude)) - if also_exclude is not None: - seen_ids.add(id(also_exclude)) - for layer in layers: - for child_guid in layer.get("children", []): - try: - child_element = tool.Ifc.get().by_guid(child_guid) - except RuntimeError: - continue - child_obj = tool.Ifc.get_object(child_element) - if child_obj is None or id(child_obj) in seen_ids: - continue - seen_ids.add(id(child_obj)) - children.append(child_obj) - return children - - -class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator): - """Hover-gated preview lines that visualise where a click-to-act wall - gizmo's operator would move the wall geometry. Four state machines: - - - **Join intersection** — when exactly two non-joined, non-collinear, - non-parallel LAYER2 walls are selected, draws one line from each wall's - nearest axis endpoint to the projected XY intersection. Each line stays - at its own wall's axis Z (so for walls on different storeys the lines - stay horizontal at their own floor levels). Mirrors the visibility of - the Join + Extend-to-Wall icons in ``GizmoWallJoinIntersection``. - - **Extend to cursor** — when a single LAYER2 wall is selected and the - ``extend`` wall-gizmo pref is enabled, draws one line from the wall's - nearer axis endpoint to the 3D cursor's projected X on the wall axis. - Mirrors the visibility of the ``extend_x_gizmo`` icon in - ``GizmoWallEdition``. - - **Extend Z to cursor** — one preview line at the cursor's projected X - from wall base to the cursor's Z, visualising the new total height. - Hover-gated on ``extend_z_gizmo``. - - **Split at cursor** — one world-vertical line at the cursor's projected X - from wall base to wall top, visualising the cut plane. Hover-gated on - ``split_gizmo``. - - Purely a visual cue — hidden by the same gizmo-preferences toggle as the - icons themselves.""" - - draw_method = "draw_lines" - - LINE_WIDTH = 1.5 - LINE_ALPHA = 0.8 - # Semi-transparent so the wall body and surrounding geometry stay visible - # under the preview quads. - QUAD_ALPHA = 0.25 - - def draw_lines(self, context: bpy.types.Context) -> None: - if not tool.Blender.are_viewport_gizmos_enabled(): - return - prefs = tool.Blender.get_addon_preferences() - # Each preview path is mutually exclusive on selection count, so they - # can short-circuit cheaply without coordinating. - self._draw_join_preview(context, prefs) - self._draw_cursor_extend_preview(context, prefs) - self._draw_cursor_extend_z_preview(context, prefs) - self._draw_cursor_split_preview(context, prefs) - - def _stroke( - self, - context: bpy.types.Context, - segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]], - color_rgb: tuple[float, float, float], - ) -> None: - _stroke_lines_alpha(context, segments, color_rgb, self.LINE_WIDTH, self.LINE_ALPHA) - - def _fill( - self, - context: bpy.types.Context, - quads: list[ - tuple[ - tuple[float, float, float], - tuple[float, float, float], - tuple[float, float, float], - tuple[float, float, float], - ] - ], - color_rgb: tuple[float, float, float], - ) -> None: - _fill_quads_alpha(context, quads, color_rgb, self.QUAD_ALPHA) - - @staticmethod - def _wall_floor_quad(mw: Matrix, x0: float, x1: float, y0: float, y1: float) -> tuple[ - tuple[float, float, float], - tuple[float, float, float], - tuple[float, float, float], - tuple[float, float, float], - ]: - """4 world-space corners of a Z=0 wall-local rectangle, CCW when - viewed from +Z. Used for top-down floor-projection quads so the - extend / split previews stay legible from plan view.""" - return ( - tuple(mw @ Vector((x0, y0, 0.0))), - tuple(mw @ Vector((x1, y0, 0.0))), - tuple(mw @ Vector((x1, y1, 0.0))), - tuple(mw @ Vector((x0, y1, 0.0))), - ) - - def _draw_join_preview(self, context: bpy.types.Context, prefs: Any) -> None: - """Render four preview lines per wall pair — two at each wall's base - Z, two at each wall's top Z — extending each axis to the projected - intersection. Two lines per wall (base + top) communicate the full - plane that the join/extend operator would weld at, not just the - floor edge. - - Hover colour: - - **Join or Fillet hover** → all four lines light up (both walls - converge at the corner; fillet is a symmetric round of the same - corner). - - **Extend-to-Wall hover** → only the base+top of the non-active - wall (the wall the default-direction operator would extend). - - Otherwise → ``decorations_colour``.""" - selected = list(tool.Blender.get_selected_objects()) - if len(selected) != 2: - return - elem_a = tool.Ifc.get_entity(selected[0]) - elem_b = tool.Ifc.get_entity(selected[1]) - if elem_a is None or elem_b is None: - return - if not tool.Parametric.is_path_connectable_wall(elem_a) or not tool.Parametric.is_path_connectable_wall(elem_b): - return - # Lazy import to avoid a circular wall.py ↔ decorator.py dependency at - # module load. The wall helpers are module-private but stable; the - # gizmo group and this decorator are the only callers, both routing - # through ``_classify_wall_join_state`` for state-machine consistency. - from bonsai.bim.module.model.wall import ( - GizmoWallJoinIntersection, - _classify_wall_join_state, - _wall_axis_world_segment_from_geom, - ) - from bonsai.core import model as core_model - - geom_a = tool.Wall.read_geometry(selected[0]) - geom_b = tool.Wall.read_geometry(selected[1]) - if geom_a is None or geom_b is None: - return - seg_a = _wall_axis_world_segment_from_geom(selected[0], geom_a) - seg_b = _wall_axis_world_segment_from_geom(selected[1], geom_b) - parallel_threshold = core_model.PARALLEL_DOT_THRESHOLD - collinear_tolerance = core_model.COLLINEAR_LINE_TOLERANCE - # Only the "intersect" state shows preview lines — joined / collinear / - # parallel each have their own gizmo icons but no extension preview. - state, intersection_tuple = _classify_wall_join_state( - elem_a, elem_b, seg_a, seg_b, parallel_threshold, collinear_tolerance - ) - if state != "intersect": - return - assert intersection_tuple is not None # tightened by the "intersect" branch - floor_lines = core_model.wall_join_preview_lines( - (tuple(seg_a[0]), tuple(seg_a[1])), - (tuple(seg_b[0]), tuple(seg_b[1])), - intersection_tuple, - ) - # Top lines mirror the floor lines but lifted by each wall's height - # (world Z, since wall axes are stored at the wall's base elevation - # and ``height`` is the world-space extrusion above that base). - height_a = geom_a.get("height", 0.0) - height_b = geom_b.get("height", 0.0) - wall_a_floor, wall_b_floor = floor_lines - - def _lift(seg: tuple, dz: float) -> tuple: - (sx, sy, sz), (ex, ey, ez) = seg - return ((sx, sy, sz + dz), (ex, ey, ez + dz)) - - wall_a_top = _lift(wall_a_floor, height_a) - wall_b_top = _lift(wall_b_floor, height_b) - # Hover semantic by operation: - # • Join / Fillet hover → all four lines (symmetric corner-meet). - # • Extend-to-Wall hover → only the wall the default-direction - # operator would actually move (the non-active wall) — both its - # base and top lines highlight. - join_hovered, extend_hovered, fillet_hovered = self._join_group_hover_state(GizmoWallJoinIntersection, context) - default = tuple(prefs.decorations_colour[:3]) - selected_rgb = tuple(prefs.decorator_color_selected[:3]) - all_lines = [wall_a_floor, wall_a_top, wall_b_floor, wall_b_top] - - if join_hovered or fillet_hovered: - self._stroke(context, all_lines, selected_rgb) - return - - if extend_hovered: - extended_idx = self._extended_wall_index(context, selected) - if extended_idx is not None: - extended_lines = [wall_a_floor, wall_a_top] if extended_idx == 0 else [wall_b_floor, wall_b_top] - untouched_lines = [wall_b_floor, wall_b_top] if extended_idx == 0 else [wall_a_floor, wall_a_top] - self._stroke(context, untouched_lines, default) - self._stroke(context, extended_lines, selected_rgb) - return - - self._stroke(context, all_lines, default) - - @staticmethod - def _extended_wall_index(context: bpy.types.Context, selected: list[bpy.types.Object]) -> Optional[int]: - """Index of the non-active wall in ``selected``, or ``None``.""" - active = context.active_object - if active is selected[0]: - return 1 - if active is selected[1]: - return 0 - return None - - def _join_group_hover_state(self, gizmo_cls: type, context: bpy.types.Context) -> tuple[bool, bool, bool]: - """Return ``(join_hovered, extend_to_wall_hovered, fillet_hovered)`` - from the ``GizmoWallJoinIntersection`` instance in **the same region** - the decorator is currently drawing in. Returns ``(False, False, - False)`` when that region has no live gizmo group (poll → False, - weakref cleared, or no setup yet). Read-only; any access exception - is swallowed so a transient bpy-state hiccup never breaks the draw - loop.""" - inst = self._lookup_active_instance(gizmo_cls, context) - if inst is None: - return False, False, False - try: - return ( - bool(inst.join_icon.is_highlight), - bool(inst.extend_to_wall_icon.is_highlight), - bool(inst.fillet_icon.is_highlight), - ) - except (AttributeError, ReferenceError): - return False, False, False - - def _active_layer2_wall_for_gizmo_preview( - self, context: bpy.types.Context, prefs: Any - ) -> Optional[bpy.types.Object]: - """Active object iff it is the sole selected object, is a LAYER2 IfcWall, - and the wall feature's gizmo prefs are enabled. Otherwise ``None``. - Shared guard for every cursor-anchored extend-preview path so each one - short-circuits on the same conditions the gizmo group itself uses.""" - gizmo_prefs = getattr(prefs.gizmos, "wall", None) - if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True): - return None - active = context.active_object - if active is None: - return None - selected = list(tool.Blender.get_selected_objects()) - if active not in selected or len(selected) != 1: - return None - element = tool.Ifc.get_entity(active) - if element is None or not tool.Parametric.is_wall(element): - return None - if tool.Model.get_usage_type(element) != "LAYER2": - return None - return active - - def _draw_cursor_extend_preview(self, context: bpy.types.Context, prefs: Any) -> None: - """Hover-gated floor-plane preview for the extend-X icon. Quads sit - on the Z=0 plane spanning the wall's ``offset`` to - ``offset + thickness`` Y band so the operator's effect reads from - plan view without side-view clutter: - - - **Cursor outside ``[anchor_x, anchor_x+length]`` (grow)**: one - green ``decorator_color_selected`` quad over the extension - (nearer endpoint → cursor X). - - **Cursor inside the wall extent (shrink)**: green quad for the - portion that REMAINS (cursor X → farther endpoint) + red - ``decorator_color_error`` quad for the portion the operator - REMOVES (nearer endpoint → cursor X).""" - active = self._active_layer2_wall_for_gizmo_preview(context, prefs) - if active is None: - return - from bonsai.bim.module.model.wall import GizmoWallEdition - - if not self._cursor_icon_hovered(GizmoWallEdition, "extend_x_gizmo", context): - return - geom = tool.Wall.read_geometry(active) - if geom is None: - return - anchor_x = geom.get("anchor_x", 0.0) - length = geom.get("length", 0.0) - offset = geom.get("offset", 0.0) - thickness = geom.get("thickness", 0.0) - if length <= 0 or thickness <= 0: - return - mw = active.matrix_world - cursor_local = mw.inverted() @ context.scene.cursor.location - y_floor_0 = offset - y_floor_1 = offset + thickness - start_x = anchor_x - end_x = anchor_x + length - keep_color = tuple(prefs.decorator_color_selected[:3]) - nearest_x = start_x if abs(cursor_local.x - start_x) < abs(cursor_local.x - end_x) else end_x - - def emit(x0: float, x1: float, color: tuple[float, float, float]) -> None: - if abs(x1 - x0) < 1e-6: - return - lo, hi = (x0, x1) if x0 < x1 else (x1, x0) - self._fill(context, [self._wall_floor_quad(mw, lo, hi, y_floor_0, y_floor_1)], color) - - if start_x < cursor_local.x < end_x: - remove_color = tuple(prefs.decorator_color_error[:3]) - farthest_x = end_x if nearest_x == start_x else start_x - emit(nearest_x, cursor_local.x, remove_color) - emit(cursor_local.x, farthest_x, keep_color) - return - emit(nearest_x, cursor_local.x, keep_color) - - def _draw_cursor_split_preview(self, context: bpy.types.Context, prefs: Any) -> None: - """Render one red line at the cursor's projected X, from wall base to wall top - along the wall's local Z — the cut plane the split operator would commit. - Hover-gated on the split icon; coloured with the destructive-action warning - red to match the icon's own hover signal.""" - active = self._active_layer2_wall_for_gizmo_preview(context, prefs) - if active is None: - return - from bonsai.bim.module.model.wall import GizmoWallEdition - - if not self._cursor_icon_hovered(GizmoWallEdition, "split_gizmo", context): - return - geom = tool.Wall.read_geometry(active) - if geom is None: - return - anchor_x = geom.get("anchor_x", 0.0) - length = geom.get("length", 0.0) - height = geom.get("height", 0.0) - if length <= 0 or height <= 0: - return - mw = active.matrix_world - cursor_local = mw.inverted() @ context.scene.cursor.location - if not (anchor_x < cursor_local.x < anchor_x + length): - return - bottom_world = mw @ Vector((cursor_local.x, 0.0, 0.0)) - top_world = mw @ Vector((cursor_local.x, 0.0, height)) - self._stroke(context, [(tuple(bottom_world), tuple(top_world))], tuple(prefs.decorator_color_error[:3])) - - def _draw_cursor_extend_z_preview(self, context: bpy.types.Context, prefs: Any) -> None: - """Hover-gated vertical-line preview for the extend-Z icon at the - cursor's projected X on the wall axis (y=0 reference-line plane). - - Two cases by cursor Z relative to the wall's current height: - - - **Cursor Z above the wall top (grow)**: one green - ``decorator_color_selected`` segment from z=height to z=cursor.z - (the new vertical material). - - **Cursor Z inside ``(0, height)`` (shrink)**: two segments — - green from z=0 to z=cursor.z (the portion that REMAINS), red - ``decorator_color_error`` from z=cursor.z to z=height (the - portion the operator REMOVES).""" - active = self._active_layer2_wall_for_gizmo_preview(context, prefs) - if active is None: - return - from bonsai.bim.module.model.wall import GizmoWallEdition - - if not self._cursor_icon_hovered(GizmoWallEdition, "extend_z_gizmo", context): - return - geom = tool.Wall.read_geometry(active) - if geom is None: - return - length = geom.get("length", 0.0) - height = geom.get("height", 0.0) - if length <= 0 or height <= 0: - return - mw = active.matrix_world - cursor_local = mw.inverted() @ context.scene.cursor.location - # New height must be > 0 for the operator to commit. - if cursor_local.z <= 0: - return - if abs(cursor_local.z - height) < 1e-6: - return - keep_color = tuple(prefs.decorator_color_selected[:3]) - cursor_x = cursor_local.x - - def stroke(z0: float, z1: float, color: tuple[float, float, float]) -> None: - a = mw @ Vector((cursor_x, 0.0, z0)) - b = mw @ Vector((cursor_x, 0.0, z1)) - self._stroke(context, [(tuple(a), tuple(b))], color) - - if cursor_local.z > height: - stroke(height, cursor_local.z, keep_color) - return - remove_color = tuple(prefs.decorator_color_error[:3]) - stroke(0.0, cursor_local.z, keep_color) - stroke(cursor_local.z, height, remove_color) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 07b1e92a90..7c60102ca7 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -56,7 +56,16 @@ from bonsai.bim.ifc import IfcStore from bonsai.bim.module.drawing import gizmos as gizmo from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot from bonsai.bim.module.model import preview_base -from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator +from bonsai.bim.module.model.decorator import ( + _BBOX_HIGHLIGHT_LINE_ALPHA, + _BBOX_HIGHLIGHT_LINE_WIDTH, + PolylineDecorator, + ProductDecorator, + _fill_quads_alpha, + _stroke_lines_alpha, + bbox_world_edges, + draw_polyline_segments, +) from bonsai.bim.module.model.polyline import PolylineOperator if TYPE_CHECKING: @@ -3558,8 +3567,6 @@ class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo): partner = self.partner_obj if partner is None: return - from bonsai.bim.module.model.decorator import draw_wall_partner_bbox - draw_wall_partner_bbox(context, partner) @@ -4084,3 +4091,355 @@ class JoinWallsIntersection(_CommitWallDraftsFirstMixin, bpy.types.Operator, too return {"CANCELLED"} _resync_walls_after_mutation(tool.Blender.get_selected_objects()) return {"FINISHED"} + + +def draw_wall_partner_bbox( + context: bpy.types.Context, + partner_obj: bpy.types.Object, +) -> None: + """Paint a wireframe bbox around ``partner_obj`` in the same 3D pass. + Called inline from gizmo ``draw()`` methods so the highlight tracks the + hover cursor one-for-one — no POST_VIEW handler, no timing lag. + + Silently no-ops if the object has no bounding box (e.g. Empties).""" + segments = bbox_world_edges(partner_obj) + if not segments: + return + prefs = tool.Blender.get_addon_preferences() + color = prefs.decorator_color_special[:3] + draw_polyline_segments( + context, + segments, + color, + _BBOX_HIGHLIGHT_LINE_ALPHA, + _BBOX_HIGHLIGHT_LINE_WIDTH, + ) + + +class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator): + """Hover-gated preview lines that visualise where a click-to-act wall + gizmo's operator would move the wall geometry. Four state machines: + + - **Join intersection** — when exactly two non-joined, non-collinear, + non-parallel LAYER2 walls are selected, draws one line from each wall's + nearest axis endpoint to the projected XY intersection. Each line stays + at its own wall's axis Z (so for walls on different storeys the lines + stay horizontal at their own floor levels). Mirrors the visibility of + the Join + Extend-to-Wall icons in ``GizmoWallJoinIntersection``. + - **Extend to cursor** — when a single LAYER2 wall is selected and the + ``extend`` wall-gizmo pref is enabled, draws one line from the wall's + nearer axis endpoint to the 3D cursor's projected X on the wall axis. + Mirrors the visibility of the ``extend_x_gizmo`` icon in + ``GizmoWallEdition``. + - **Extend Z to cursor** — one preview line at the cursor's projected X + from wall base to the cursor's Z, visualising the new total height. + Hover-gated on ``extend_z_gizmo``. + - **Split at cursor** — one world-vertical line at the cursor's projected X + from wall base to wall top, visualising the cut plane. Hover-gated on + ``split_gizmo``. + + Purely a visual cue — hidden by the same gizmo-preferences toggle as the + icons themselves.""" + + draw_method = "draw_lines" + + LINE_WIDTH = 1.5 + LINE_ALPHA = 0.8 + QUAD_ALPHA = 0.25 + + def draw_lines(self, context: bpy.types.Context) -> None: + if not tool.Blender.are_viewport_gizmos_enabled(): + return + prefs = tool.Blender.get_addon_preferences() + self._draw_join_preview(context, prefs) + self._draw_cursor_extend_preview(context, prefs) + self._draw_cursor_extend_z_preview(context, prefs) + self._draw_cursor_split_preview(context, prefs) + + def _stroke( + self, + context: bpy.types.Context, + segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]], + color_rgb: tuple[float, float, float], + ) -> None: + _stroke_lines_alpha(context, segments, color_rgb, self.LINE_WIDTH, self.LINE_ALPHA) + + def _fill( + self, + context: bpy.types.Context, + quads: list[ + tuple[ + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + ] + ], + color_rgb: tuple[float, float, float], + ) -> None: + _fill_quads_alpha(context, quads, color_rgb, self.QUAD_ALPHA) + + @staticmethod + def _wall_floor_quad(mw: Matrix, x0: float, x1: float, y0: float, y1: float) -> tuple[ + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + ]: + """4 world-space corners of a Z=0 wall-local rectangle, CCW when + viewed from +Z. Used for top-down floor-projection quads so the + extend / split previews stay legible from plan view.""" + return ( + tuple(mw @ Vector((x0, y0, 0.0))), + tuple(mw @ Vector((x1, y0, 0.0))), + tuple(mw @ Vector((x1, y1, 0.0))), + tuple(mw @ Vector((x0, y1, 0.0))), + ) + + def _draw_join_preview(self, context: bpy.types.Context, prefs: Any) -> None: + """Render four preview lines per wall pair — two at each wall's base + Z, two at each wall's top Z — extending each axis to the projected + intersection. Two lines per wall (base + top) communicate the full + plane that the join/extend operator would weld at, not just the + floor edge. + + Hover colour: + - **Join or Fillet hover** → all four lines light up (both walls + converge at the corner; fillet is a symmetric round of the same + corner). + - **Extend-to-Wall hover** → only the base+top of the non-active + wall (the wall the default-direction operator would extend). + - Otherwise → ``decorations_colour``.""" + selected = list(tool.Blender.get_selected_objects()) + if len(selected) != 2: + return + elem_a = tool.Ifc.get_entity(selected[0]) + elem_b = tool.Ifc.get_entity(selected[1]) + if elem_a is None or elem_b is None: + return + if not tool.Parametric.is_path_connectable_wall(elem_a) or not tool.Parametric.is_path_connectable_wall(elem_b): + return + + geom_a = tool.Wall.read_geometry(selected[0]) + geom_b = tool.Wall.read_geometry(selected[1]) + if geom_a is None or geom_b is None: + return + seg_a = _wall_axis_world_segment_from_geom(selected[0], geom_a) + seg_b = _wall_axis_world_segment_from_geom(selected[1], geom_b) + parallel_threshold = core.PARALLEL_DOT_THRESHOLD + collinear_tolerance = core.COLLINEAR_LINE_TOLERANCE + state, intersection_tuple = _classify_wall_join_state( + elem_a, elem_b, seg_a, seg_b, parallel_threshold, collinear_tolerance + ) + if state != "intersect": + return + assert intersection_tuple is not None + floor_lines = core.wall_join_preview_lines( + (tuple(seg_a[0]), tuple(seg_a[1])), + (tuple(seg_b[0]), tuple(seg_b[1])), + intersection_tuple, + ) + height_a = geom_a.get("height", 0.0) + height_b = geom_b.get("height", 0.0) + wall_a_floor, wall_b_floor = floor_lines + + def _lift(seg: tuple, dz: float) -> tuple: + (sx, sy, sz), (ex, ey, ez) = seg + return ((sx, sy, sz + dz), (ex, ey, ez + dz)) + + wall_a_top = _lift(wall_a_floor, height_a) + wall_b_top = _lift(wall_b_floor, height_b) + join_hovered, extend_hovered, fillet_hovered = self._join_group_hover_state(GizmoWallJoinIntersection, context) + default = tuple(prefs.decorations_colour[:3]) + selected_rgb = tuple(prefs.decorator_color_selected[:3]) + all_lines = [wall_a_floor, wall_a_top, wall_b_floor, wall_b_top] + + if join_hovered or fillet_hovered: + self._stroke(context, all_lines, selected_rgb) + return + + if extend_hovered: + extended_idx = self._extended_wall_index(context, selected) + if extended_idx is not None: + extended_lines = [wall_a_floor, wall_a_top] if extended_idx == 0 else [wall_b_floor, wall_b_top] + untouched_lines = [wall_b_floor, wall_b_top] if extended_idx == 0 else [wall_a_floor, wall_a_top] + self._stroke(context, untouched_lines, default) + self._stroke(context, extended_lines, selected_rgb) + return + + self._stroke(context, all_lines, default) + + @staticmethod + def _extended_wall_index(context: bpy.types.Context, selected: list[bpy.types.Object]) -> Optional[int]: + """Index of the non-active wall in ``selected``, or ``None``.""" + active = context.active_object + if active is selected[0]: + return 1 + if active is selected[1]: + return 0 + return None + + def _join_group_hover_state(self, gizmo_cls: type, context: bpy.types.Context) -> tuple[bool, bool, bool]: + """Return ``(join_hovered, extend_to_wall_hovered, fillet_hovered)`` + from the ``GizmoWallJoinIntersection`` instance in **the same region** + the decorator is currently drawing in. Returns ``(False, False, + False)`` when that region has no live gizmo group (poll → False, + weakref cleared, or no setup yet). Read-only; any access exception + is swallowed so a transient bpy-state hiccup never breaks the draw + loop.""" + inst = self._lookup_active_instance(gizmo_cls, context) + if inst is None: + return False, False, False + try: + return ( + bool(inst.join_icon.is_highlight), + bool(inst.extend_to_wall_icon.is_highlight), + bool(inst.fillet_icon.is_highlight), + ) + except (AttributeError, ReferenceError): + return False, False, False + + def _active_layer2_wall_for_gizmo_preview( + self, context: bpy.types.Context, prefs: Any + ) -> Optional[bpy.types.Object]: + """Active object iff it is the sole selected object, is a LAYER2 IfcWall, + and the wall feature's gizmo prefs are enabled. Otherwise ``None``. + Shared guard for every cursor-anchored extend-preview path so each one + short-circuits on the same conditions the gizmo group itself uses.""" + gizmo_prefs = getattr(prefs.gizmos, "wall", None) + if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True): + return None + active = context.active_object + if active is None: + return None + selected = list(tool.Blender.get_selected_objects()) + if active not in selected or len(selected) != 1: + return None + element = tool.Ifc.get_entity(active) + if element is None or not tool.Parametric.is_wall(element): + return None + if tool.Model.get_usage_type(element) != "LAYER2": + return None + return active + + def _draw_cursor_extend_preview(self, context: bpy.types.Context, prefs: Any) -> None: + """Hover-gated floor-plane preview for the extend-X icon. Quads sit + on the Z=0 plane spanning the wall's ``offset`` to + ``offset + thickness`` Y band so the operator's effect reads from + plan view without side-view clutter: + + - **Cursor outside ``[anchor_x, anchor_x+length]`` (grow)**: one + green ``decorator_color_selected`` quad over the extension + (nearer endpoint → cursor X). + - **Cursor inside the wall extent (shrink)**: green quad for the + portion that REMAINS (cursor X → farther endpoint) + red + ``decorator_color_error`` quad for the portion the operator + REMOVES (nearer endpoint → cursor X).""" + active = self._active_layer2_wall_for_gizmo_preview(context, prefs) + if active is None: + return + if not self._cursor_icon_hovered(GizmoWallEdition, "extend_x_gizmo", context): + return + geom = tool.Wall.read_geometry(active) + if geom is None: + return + anchor_x = geom.get("anchor_x", 0.0) + length = geom.get("length", 0.0) + offset = geom.get("offset", 0.0) + thickness = geom.get("thickness", 0.0) + if length <= 0 or thickness <= 0: + return + mw = active.matrix_world + cursor_local = mw.inverted() @ context.scene.cursor.location + y_floor_0 = offset + y_floor_1 = offset + thickness + start_x = anchor_x + end_x = anchor_x + length + keep_color = tuple(prefs.decorator_color_selected[:3]) + nearest_x = start_x if abs(cursor_local.x - start_x) < abs(cursor_local.x - end_x) else end_x + + def emit(x0: float, x1: float, color: tuple[float, float, float]) -> None: + if abs(x1 - x0) < 1e-6: + return + lo, hi = (x0, x1) if x0 < x1 else (x1, x0) + self._fill(context, [self._wall_floor_quad(mw, lo, hi, y_floor_0, y_floor_1)], color) + + if start_x < cursor_local.x < end_x: + remove_color = tuple(prefs.decorator_color_error[:3]) + farthest_x = end_x if nearest_x == start_x else start_x + emit(nearest_x, cursor_local.x, remove_color) + emit(cursor_local.x, farthest_x, keep_color) + return + emit(nearest_x, cursor_local.x, keep_color) + + def _draw_cursor_split_preview(self, context: bpy.types.Context, prefs: Any) -> None: + """Render one red line at the cursor's projected X, from wall base to wall top + along the wall's local Z — the cut plane the split operator would commit. + Hover-gated on the split icon; coloured with the destructive-action warning + red to match the icon's own hover signal.""" + active = self._active_layer2_wall_for_gizmo_preview(context, prefs) + if active is None: + return + if not self._cursor_icon_hovered(GizmoWallEdition, "split_gizmo", context): + return + geom = tool.Wall.read_geometry(active) + if geom is None: + return + anchor_x = geom.get("anchor_x", 0.0) + length = geom.get("length", 0.0) + height = geom.get("height", 0.0) + if length <= 0 or height <= 0: + return + mw = active.matrix_world + cursor_local = mw.inverted() @ context.scene.cursor.location + if not (anchor_x < cursor_local.x < anchor_x + length): + return + bottom_world = mw @ Vector((cursor_local.x, 0.0, 0.0)) + top_world = mw @ Vector((cursor_local.x, 0.0, height)) + self._stroke(context, [(tuple(bottom_world), tuple(top_world))], tuple(prefs.decorator_color_error[:3])) + + def _draw_cursor_extend_z_preview(self, context: bpy.types.Context, prefs: Any) -> None: + """Hover-gated vertical-line preview for the extend-Z icon at the + cursor's projected X on the wall axis (y=0 reference-line plane). + + Two cases by cursor Z relative to the wall's current height: + + - **Cursor Z above the wall top (grow)**: one green + ``decorator_color_selected`` segment from z=height to z=cursor.z + (the new vertical material). + - **Cursor Z inside ``(0, height)`` (shrink)**: two segments — + green from z=0 to z=cursor.z (the portion that REMAINS), red + ``decorator_color_error`` from z=cursor.z to z=height (the + portion the operator REMOVES).""" + active = self._active_layer2_wall_for_gizmo_preview(context, prefs) + if active is None: + return + if not self._cursor_icon_hovered(GizmoWallEdition, "extend_z_gizmo", context): + return + geom = tool.Wall.read_geometry(active) + if geom is None: + return + length = geom.get("length", 0.0) + height = geom.get("height", 0.0) + if length <= 0 or height <= 0: + return + mw = active.matrix_world + cursor_local = mw.inverted() @ context.scene.cursor.location + if cursor_local.z <= 0: + return + if abs(cursor_local.z - height) < 1e-6: + return + keep_color = tuple(prefs.decorator_color_selected[:3]) + cursor_x = cursor_local.x + + def stroke(z0: float, z1: float, color: tuple[float, float, float]) -> None: + a = mw @ Vector((cursor_x, 0.0, z0)) + b = mw @ Vector((cursor_x, 0.0, z1)) + self._stroke(context, [(tuple(a), tuple(b))], color) + + if cursor_local.z > height: + stroke(height, cursor_local.z, keep_color) + return + remove_color = tuple(prefs.decorator_color_error[:3]) + stroke(0.0, cursor_local.z, keep_color) + stroke(cursor_local.z, height, remove_color)