Show wall cursor gizmos outside edit mode + axis previews

Four concerns that together make the cursor-anchored gizmos
(extend_x_gizmo, extend_z_gizmo, split_gizmo on GizmoWallEdition)
fully functional and visually informative without entering parametric
edit mode first:

* Drop the props.is_editing gate in _update_cursor_gizmos. The three
  bound operators (bim.extend_wall_to_cursor,
  bim.extend_wall_height_to_cursor, bim.split_wall_at_cursor) already
  poll on wall-selected and commit any pending wall edit before
  acting, so single-click without entering edit mode is now the
  canonical flow. Matches gizmos-8088's always-on behaviour.

* Register GizmoWallEdition instances in a per-region weakref map
  (_active_instances) populated at setup_element_specific_gizmos
  time. The WallGizmoPreviewDecorator dereferences this map to read
  live is_highlight state off the cursor icons. Without the
  registration its _cursor_icon_hovered always returned False and
  the hover-gated GPU previews silently never drew. Mirrors the
  same pattern already in place on GizmoWallJoinIntersection.

* Add post-operator resync to all three cursor operators
  (_maybe_resync_wall_props_from_ifc for the single-wall split /
  extend-height paths, _resync_walls_after_mutation for the
  selection-wide extend-X path). Without this, props.length /
  props.height stayed stale after the operator ran, so the
  orientation flips _apply_wall_extend_flips computes from
  cursor_local vs wall dimensions kept using the pre-extend values
  until the next selection change. Matches gizmos-8088's pattern.

* Hover-gated GPU previews per icon:

  - extend-X: filled Z=0 floor quads spanning the wall's offset to
    offset+thickness Y band, visible from plan view without side-
    view clutter. Grow case (cursor beyond either endpoint): one
    green decorator_color_selected quad over the extension. Shrink
    case (cursor inside extent): green quad for the portion that
    REMAINS + red decorator_color_error quad for the portion the
    operator REMOVES.

  - extend-Z: vertical lines at the cursor's projected X in the
    wall's y=0 reference-line plane. Grow case (cursor above wall
    top): one green segment from z=height to z=cursor.z. Shrink
    case: green from z=0 to z=cursor.z (REMAINS) + red from
    z=cursor.z to z=height (REMOVES).

  - split: one red vertical line at the cursor's projected X from
    base to wall top — the cut plane.

  Quads use QUAD_ALPHA=0.25 so the underlying wall body stays
  visible.

* New module-level _fill_quads_alpha helper next to
  _stroke_lines_alpha, plus a per-decorator _fill convenience method
  and a _wall_floor_quad corner builder.

Modal-active gizmo hiding (is_gizmo_hidden_by_modal) is preserved.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-03 13:57:39 +02:00
parent 99bb1e30ad
commit ab64b652ff
2 changed files with 156 additions and 22 deletions
+134 -17
View File
@@ -2069,6 +2069,47 @@ def _stroke_lines_alpha(
gpu.state.blend_set("NONE")
def _fill_quads_alpha(
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],
alpha: float,
) -> None:
"""Render ``quads`` (each a 4-tuple of world-space corner verts in CCW
order) as one TRIS batch with two triangles per quad. Companion to
``_stroke_lines_alpha`` for filled previews."""
if not quads:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int, int]] = []
for quad in quads:
if len(quad) != 4:
continue
base = len(verts)
verts.extend(tuple(v) for v in quad)
indices.append((base, base + 1, base + 2))
indices.append((base, base + 2, base + 3))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
shader.bind()
shader.uniform_float("color", (*color_rgb, alpha))
batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
"""GPU preview lines for the wall-fillet flow.
@@ -2543,6 +2584,9 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
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():
@@ -2563,6 +2607,38 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
) -> 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
@@ -2710,11 +2786,18 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
return active
def _draw_cursor_extend_preview(self, context: bpy.types.Context, prefs: Any) -> None:
"""Render the extend-X preview line only while the cursor-anchored
extend-X icon gizmo is hovered. The line runs from the wall's nearer
axis endpoint to the cursor's projected X on the wall axis, both
clamped to wall-local Y=0 Z=0 so the line stays on the wall's floor
edge regardless of cursor Z."""
"""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
@@ -2727,21 +2810,35 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
return
anchor_x = geom.get("anchor_x", 0.0)
length = geom.get("length", 0.0)
if length <= 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
start_world = mw @ Vector((nearest_x, 0.0, 0.0))
end_world = mw @ Vector((cursor_local.x, 0.0, 0.0))
if (end_world - start_world).length < 1e-6:
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
self._stroke(context, [(tuple(start_world), tuple(end_world))], tuple(prefs.decorator_color_selected[:3]))
emit(nearest_x, cursor_local.x, keep_color)
def _draw_cursor_split_preview(self, context: bpy.types.Context, prefs: Any) -> None:
"""Render one line at the cursor's projected X, from wall base to wall top
"""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."""
@@ -2769,9 +2866,18 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
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:
"""Render one preview line at the cursor's projected X on the wall axis,
from the wall base to the gizmo's local Z — the new total height the
extend-Z operator would commit. Hover-gated on the extend-Z icon."""
"""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
@@ -2793,6 +2899,17 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
return
if abs(cursor_local.z - height) < 1e-6:
return
base_world = mw @ Vector((cursor_local.x, 0.0, 0.0))
top_world = mw @ Vector((cursor_local.x, 0.0, cursor_local.z))
self._stroke(context, [(tuple(base_world), tuple(top_world))], tuple(prefs.decorator_color_selected[:3]))
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)
+22 -5
View File
@@ -2011,6 +2011,13 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
(0, 0, 1),
)
# Per-region weakref map populated in ``setup_element_specific_gizmos``.
# The ``WallGizmoPreviewDecorator`` dereferences this each draw to read
# live ``is_highlight`` state off the cursor icons (extend-X / extend-Z
# / split) in the same region it's currently drawing in, so the GPU
# axis-preview lines only render while the matching icon is hovered.
_active_instances: ClassVar["dict[int, weakref.ReferenceType[GizmoWallEdition]]"] = {}
# Row layout: validate / cancel / baseline-triplet / rotate / array.
# Wall has no ``cycle_type_operator``, so the cycle slot collapses and
# the baseline triplet takes the cycle X position (0.87). Rotate
@@ -2073,6 +2080,8 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
highlight_color,
)
self.setup_pen_row_toggle_openings_icon()
if context.region is not None:
type(self)._active_instances[context.region.as_pointer()] = weakref.ref(self)
def _refresh_element_specific(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None:
"""Position cursor-anchored gizmos and the wall-specific icon-row extras."""
@@ -2087,6 +2096,14 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
"""Position the cursor-anchored icons (extend-X / extend-Z / split) on the wall
axis at the cursor's projected X.
Always visible when a parametric wall is selected — not gated on edit
mode. The three operators (``bim.extend_wall_to_cursor`` /
``bim.extend_wall_height_to_cursor`` / ``bim.split_wall_at_cursor``)
all poll on wall-selected and commit any pending wall edit before
acting, so single-click without entering edit mode is the canonical
flow. The ``WallGizmoPreviewDecorator`` keeps the viewport clean by
only drawing the action's guide line on hover.
Two branches by view orientation:
- **Non-top-down**: world-Z stacking. Each icon sits at the Z its
@@ -2101,10 +2118,6 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
if not hasattr(self, "split_gizmo"):
return
all_gizmos = (self.extend_x_gizmo, self.extend_z_gizmo, self.split_gizmo)
if not props.is_editing:
for gz in all_gizmos:
gz.hide = True
return
cursor_world = context.scene.cursor.location
cursor_local = mw.inverted() @ cursor_world
in_range = props.anchor_x < cursor_local.x < props.anchor_x + props.length
@@ -2252,9 +2265,11 @@ class SplitWallAtCursor(bpy.types.Operator, tool.Ifc.Operator):
def _execute(self, context: bpy.types.Context) -> set[str]:
# Applies any pending wall edit first so the split operates on the committed
# geometry rather than the draft preview box.
if _commit_active_wall_edit_if_any(context) is None:
obj = _commit_active_wall_edit_if_any(context)
if obj is None:
return {"CANCELLED"}
bpy.ops.bim.split_wall()
_maybe_resync_wall_props_from_ifc(obj)
return {"FINISHED"}
@@ -2282,6 +2297,7 @@ class ExtendWallToCursor(bpy.types.Operator, tool.Ifc.Operator):
tool.Model,
context.scene.cursor.location,
)
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
return {"FINISHED"}
@@ -2313,6 +2329,7 @@ class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator):
return {"CANCELLED"}
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
bpy.ops.bim.change_extrusion_depth(depth=new_height)
_maybe_resync_wall_props_from_ifc(obj)
return {"FINISHED"}