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
committed by Thomas Krijnen
parent 6391dbebb5
commit e3738999c1
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.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__))
@@ -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)
+264
View File
@@ -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
+1 -640
View File
@@ -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)
+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.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)