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.
This commit is contained in:
Gorgious56
2026-06-05 12:45:51 +02:00
parent a30546f1f2
commit 87bca20df7
5 changed files with 634 additions and 648 deletions
+5 -3
View File
@@ -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.aggregate.decorator import AggregateDecorator
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
from bonsai.bim.module.model import wall_offset_gizmos from bonsai.bim.module.model import wall_offset_gizmos
from bonsai.bim.module.model.data import AuthoringData from bonsai.bim.module.model.array import (
from bonsai.bim.module.model.decorator import (
ArrayPreviewDecorator, ArrayPreviewDecorator,
ArraySelectionHighlightDecorator, ArraySelectionHighlightDecorator,
)
from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.model.decorator import (
BoundingBoxDecorator, BoundingBoxDecorator,
SlabDirectionDecorator, SlabDirectionDecorator,
WallAxisDecorator, WallAxisDecorator,
WallFilletPreviewDecorator, WallFilletPreviewDecorator,
WallGizmoPreviewDecorator,
) )
from bonsai.bim.module.model.preview_base import discard_pending_previews 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 from bonsai.bim.module.nest.decorator import NestDecorator
cwd = os.path.dirname(os.path.realpath(__file__)) cwd = os.path.dirname(os.path.realpath(__file__))
@@ -3792,7 +3792,7 @@ class GizmoArrayAll(StaticTrisGizmoMixin, bpy.types.Gizmo):
parent_element = tool.Ifc.get().by_guid(parent_guid) parent_element = tool.Ifc.get().by_guid(parent_guid)
except RuntimeError: except RuntimeError:
return 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) 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) parent_element = tool.Ifc.get_entity(obj)
if parent_element is None: if parent_element is None:
return 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) draw_array_layer_children_bbox(context, parent_element, self._layer_index)
+264
View File
@@ -28,12 +28,20 @@ from mathutils import Matrix, Vector
import bonsai.bim.module.drawing.gizmos as gizmo import bonsai.bim.module.drawing.gizmos as gizmo
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim.decorator_cache import TokenCache
from bonsai.bim.module.drawing.gizmos import ( from bonsai.bim.module.drawing.gizmos import (
COLOR_GREEN, COLOR_GREEN,
COLOR_RED, COLOR_RED,
DimensionGizmoConfig, DimensionGizmoConfig,
IconSlot, 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 ( from bonsai.bim.parametric_lifecycle import (
IntegerInputDialogMixin, IntegerInputDialogMixin,
ParametricEditMixinBase, ParametricEditMixinBase,
@@ -1410,3 +1418,259 @@ class GizmoArrayChild(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
gz = getattr(self, name) gz = getattr(self, name)
world_pos = mw @ Vector((x, 0, bbox_top + self.ICON_Z_OFFSET)) 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) 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
+1 -640
View File
@@ -18,10 +18,9 @@
from __future__ import annotations from __future__ import annotations
import json
import math import math
from math import cos, pi, radians, sin, tan from math import cos, pi, radians, sin, tan
from typing import Any, Literal, Optional from typing import Any, Literal
import blf import blf
import bmesh import bmesh
@@ -42,7 +41,6 @@ from mathutils import Matrix, Quaternion, Vector
import bonsai.core.geometry import bonsai.core.geometry
import bonsai.tool as tool import bonsai.tool as tool
from bonsai.bim.decorator_cache import TokenCache
from bonsai.bim.module.drawing.helper import format_distance 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_WIDTH = 1.8
_BBOX_HIGHLIGHT_LINE_ALPHA = 0.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)
+362 -3
View File
@@ -56,7 +56,16 @@ from bonsai.bim.ifc import IfcStore
from bonsai.bim.module.drawing import gizmos as gizmo from bonsai.bim.module.drawing import gizmos as gizmo
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot
from bonsai.bim.module.model import preview_base 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 from bonsai.bim.module.model.polyline import PolylineOperator
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -3558,8 +3567,6 @@ class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo):
partner = self.partner_obj partner = self.partner_obj
if partner is None: if partner is None:
return return
from bonsai.bim.module.model.decorator import draw_wall_partner_bbox
draw_wall_partner_bbox(context, partner) draw_wall_partner_bbox(context, partner)
@@ -4084,3 +4091,355 @@ class JoinWallsIntersection(_CommitWallDraftsFirstMixin, bpy.types.Operator, too
return {"CANCELLED"} return {"CANCELLED"}
_resync_walls_after_mutation(tool.Blender.get_selected_objects()) _resync_walls_after_mutation(tool.Blender.get_selected_objects())
return {"FINISHED"} 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)