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") 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): class WallFilletPreviewDecorator(tool.Blender.ViewportDecorator):
"""GPU preview lines for the wall-fillet flow. """GPU preview lines for the wall-fillet flow.
@@ -2543,6 +2584,9 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
LINE_WIDTH = 1.5 LINE_WIDTH = 1.5
LINE_ALPHA = 0.8 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: def draw_lines(self, context: bpy.types.Context) -> None:
if not tool.Blender.are_viewport_gizmos_enabled(): if not tool.Blender.are_viewport_gizmos_enabled():
@@ -2563,6 +2607,38 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
) -> None: ) -> None:
_stroke_lines_alpha(context, segments, color_rgb, self.LINE_WIDTH, self.LINE_ALPHA) _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: 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 """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 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 return active
def _draw_cursor_extend_preview(self, context: bpy.types.Context, prefs: Any) -> None: def _draw_cursor_extend_preview(self, context: bpy.types.Context, prefs: Any) -> None:
"""Render the extend-X preview line only while the cursor-anchored """Hover-gated floor-plane preview for the extend-X icon. Quads sit
extend-X icon gizmo is hovered. The line runs from the wall's nearer on the Z=0 plane spanning the wall's ``offset`` to
axis endpoint to the cursor's projected X on the wall axis, both ``offset + thickness`` Y band so the operator's effect reads from
clamped to wall-local Y=0 Z=0 so the line stays on the wall's floor plan view without side-view clutter:
edge regardless of cursor Z."""
- **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) active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
if active is None: if active is None:
return return
@@ -2727,21 +2810,35 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
return return
anchor_x = geom.get("anchor_x", 0.0) anchor_x = geom.get("anchor_x", 0.0)
length = geom.get("length", 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 return
mw = active.matrix_world mw = active.matrix_world
cursor_local = mw.inverted() @ context.scene.cursor.location cursor_local = mw.inverted() @ context.scene.cursor.location
y_floor_0 = offset
y_floor_1 = offset + thickness
start_x = anchor_x start_x = anchor_x
end_x = anchor_x + length 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 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)) def emit(x0: float, x1: float, color: tuple[float, float, float]) -> None:
if (end_world - start_world).length < 1e-6: 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 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: 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. 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 Hover-gated on the split icon; coloured with the destructive-action warning
red to match the icon's own hover signal.""" 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])) 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: 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, """Hover-gated vertical-line preview for the extend-Z icon at the
from the wall base to the gizmo's local Z — the new total height the cursor's projected X on the wall axis (y=0 reference-line plane).
extend-Z operator would commit. Hover-gated on the extend-Z icon."""
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) active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
if active is None: if active is None:
return return
@@ -2793,6 +2899,17 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
return return
if abs(cursor_local.z - height) < 1e-6: if abs(cursor_local.z - height) < 1e-6:
return return
base_world = mw @ Vector((cursor_local.x, 0.0, 0.0)) keep_color = tuple(prefs.decorator_color_selected[:3])
top_world = mw @ Vector((cursor_local.x, 0.0, cursor_local.z)) cursor_x = cursor_local.x
self._stroke(context, [(tuple(base_world), tuple(top_world))], tuple(prefs.decorator_color_selected[:3]))
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), (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. # Row layout: validate / cancel / baseline-triplet / rotate / array.
# Wall has no ``cycle_type_operator``, so the cycle slot collapses and # Wall has no ``cycle_type_operator``, so the cycle slot collapses and
# the baseline triplet takes the cycle X position (0.87). Rotate # the baseline triplet takes the cycle X position (0.87). Rotate
@@ -2073,6 +2080,8 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
highlight_color, highlight_color,
) )
self.setup_pen_row_toggle_openings_icon() 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: 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.""" """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 """Position the cursor-anchored icons (extend-X / extend-Z / split) on the wall
axis at the cursor's projected X. 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: Two branches by view orientation:
- **Non-top-down**: world-Z stacking. Each icon sits at the Z its - **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"): if not hasattr(self, "split_gizmo"):
return return
all_gizmos = (self.extend_x_gizmo, self.extend_z_gizmo, self.split_gizmo) 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_world = context.scene.cursor.location
cursor_local = mw.inverted() @ cursor_world cursor_local = mw.inverted() @ cursor_world
in_range = props.anchor_x < cursor_local.x < props.anchor_x + props.length 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]: def _execute(self, context: bpy.types.Context) -> set[str]:
# Applies any pending wall edit first so the split operates on the committed # Applies any pending wall edit first so the split operates on the committed
# geometry rather than the draft preview box. # 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"} return {"CANCELLED"}
bpy.ops.bim.split_wall() bpy.ops.bim.split_wall()
_maybe_resync_wall_props_from_ifc(obj)
return {"FINISHED"} return {"FINISHED"}
@@ -2282,6 +2297,7 @@ class ExtendWallToCursor(bpy.types.Operator, tool.Ifc.Operator):
tool.Model, tool.Model,
context.scene.cursor.location, context.scene.cursor.location,
) )
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
return {"FINISHED"} return {"FINISHED"}
@@ -2313,6 +2329,7 @@ class ExtendWallHeightToCursor(bpy.types.Operator, tool.Ifc.Operator):
return {"CANCELLED"} return {"CANCELLED"}
with bpy.context.temp_override(active_object=obj, selected_objects=[obj]): with bpy.context.temp_override(active_object=obj, selected_objects=[obj]):
bpy.ops.bim.change_extrusion_depth(depth=new_height) bpy.ops.bim.change_extrusion_depth(depth=new_height)
_maybe_resync_wall_props_from_ifc(obj)
return {"FINISHED"} return {"FINISHED"}