Source code for wolfhece._sculpt_manager

"""Sculpt brush and profile brush companion object for WolfMapViewer.

All sculpt-brush and profile-brush interaction logic lives here.
``WolfMapViewer`` holds a single instance as ``self._sculpt`` and exposes
one-line delegators for every method so that external callers remain
unaffected.

Design notes
------------
* ``SculptManager`` owns all *private* transient state (cursor positions,
  pressure, show-overlay flags, rect-rotation helpers, brush-refresh
  coalescing flag).  The *public* panel references (``sculpt_panel``,
  ``profile_panel``) stay as regular attributes on ``WolfMapViewer`` because
  ``wolf_sculpt.py`` sets them directly (``mapviewer.sculpt_panel = self``).
* Six *event-hook* methods let the viewer delegate its mouse / keyboard /
  action events with a single call.
* wx parent for dialogs is always ``self._viewer``.
"""
from __future__ import annotations

import logging
import math
from typing import TYPE_CHECKING, Any, Optional

from ._viewer_plugin_handlers import KeyboardSnapshot, MouseContext
from .PyTranslate import _
from .wolf_sculpt import BrushShape, SculptMode

if TYPE_CHECKING:
    from .PyDraw import WolfMapViewer

[docs] class SculptManager: """Companion object that owns sculpt-brush and profile-brush logic.""" # ------------------------------------------------------------------ # # Construction # # ------------------------------------------------------------------ # def __init__(self, viewer: 'WolfMapViewer') -> None:
[docs] self._viewer = viewer
# Sculpt state (was self._sculpt_* on WolfMapViewer)
[docs] self.cursor_xy: Optional[tuple] = None # world pos of brush cursor
[docs] self.pressure: float = 1.0 # last known stylus pressure
[docs] self.show_size: bool = False # show radius overlay briefly
[docs] self.show_zone: bool = False # show search-zone overlay briefly
# Profile state (was self._profile_* on WolfMapViewer)
[docs] self.profile_cursor_xy: Optional[tuple] = None # world pos for ghost cursor
[docs] self.profile_last_seg: Optional[tuple] = None # (fx,fy,hx,hy,px,py,chw)
# Brush-refresh coalescing (was self._brush_refresh_pending)
[docs] self.brush_refresh_pending: bool = False
# Rectangle-rotation transient state (sculpt brush, R-held mode)
[docs] self._rect_rot_center_xy: Optional[tuple] = None
[docs] self._rect_rot_angle_offset: float = 0.0
[docs] self._rect_rot_calibrated: bool = False
# ------------------------------------------------------------------ # # Event hooks # # ------------------------------------------------------------------ #
[docs] def on_wheel(self, ctx: MouseContext) -> bool: """Handle mouse-wheel brush controls when action == 'sculpt'. Parameters ---------- ctx: :class:`MouseContext` built from the wheel event. The relevant fields are ``shift``, ``ctrl``, ``alt``, ``wheel_rotation`` and ``wheel_delta``. Returns ``True`` if the event was fully consumed (caller should return). """ viewer = self._viewer if viewer.action == 'sculpt' and (ctx.shift or ctx.ctrl): panel = viewer.sculpt_panel if panel is not None: step = ctx.wheel_rotation / max(ctx.wheel_delta, 1) brush = panel.brush if ctx.alt and ctx.shift and not ctx.ctrl: # ALT+SHIFT+wheel → search zone factor brush.search_zone_factor = max( 1.0, brush.search_zone_factor * (1.0 + 0.1 * step)) self.show_zone = True self._viewer.schedule_once(1500, self.hide_zone) panel.sync_sliders() viewer.Refresh() return True if ctx.shift and not ctx.ctrl and not ctx.alt: # SHIFT+wheel → brush size factor = max(0.2, 1.0 + 0.1 * step) if brush.shape == BrushShape.RECTANGLE: brush.rect_width = max(0.5, brush.rect_width * factor) brush.rect_height = max(0.5, brush.rect_height * factor) else: brush.radius = max(1.0, brush.radius * factor) self.show_size = True self._viewer.schedule_once(1500, self.hide_size) elif ctx.ctrl and not ctx.shift and not ctx.alt: # CTRL+wheel → intensity brush.intensity = max(0.0, min(1.0, brush.intensity + 0.05 * step)) elif ctx.ctrl and ctx.shift and not ctx.alt: # CTRL+SHIFT+wheel → strength brush.strength = max(0.0, brush.strength + 0.1 * step) panel.sync_sliders() viewer.Refresh() return True return False
[docs] def on_left_down(self, ctx: MouseContext) -> bool: """Handle left-button press for sculpt / profile actions. Parameters ---------- ctx: :class:`MouseContext` built from the left-down event. The relevant fields are ``x``, ``y``, ``left_down`` and ``shift``. Returns ``True`` if the event was consumed (caller should return). """ viewer = self._viewer if viewer.action == 'profile' and ctx.left_down: if ctx.shift: pp = viewer.profile_panel if pp is not None and viewer.active_array is not None: pp._on_refine_eikonal(None) else: self.profile_apply_at(ctx.x, ctx.y) return True return False
[docs] def on_motion(self, ctx: MouseContext) -> bool: """Handle mouse motion for sculpt / profile actions. Parameters ---------- ctx: Pre-built :class:`MouseContext` with world coordinates, pixel coordinates, modifier flags, button states and stylus pressure. The caller (``On_Mouse_Motion``) stores this object in ``viewer._mouse_context`` *before* calling this method, so tooltip helpers can read it directly. Returns ``True`` when the event is fully consumed (caller should return). Returns ``False`` when sculpt middle-drag must fall through to the pan code, or when the action is neither 'sculpt' nor 'profile'. """ viewer = self._viewer x, y = ctx.x, ctx.y # ── Sculpt action ────────────────────────────────────────────── if viewer.action == 'sculpt': panel = viewer.sculpt_panel # R held + RECTANGLE → polar-angle rotation mode if ( panel is not None and panel.brush.shape == BrushShape.RECTANGLE and ctx.keyboard.is_key_down(ord('R')) ): if self._rect_rot_center_xy is None: self._rect_rot_center_xy = (x, y) self._rect_rot_angle_offset = panel.brush.rect_angle cx_r, cy_r = self._rect_rot_center_xy self.cursor_xy = (cx_r, cy_r) dx_w = x - cx_r dy_w = y - cy_r if abs(dx_w) > 1e-9 or abs(dy_w) > 1e-9: polar = math.degrees(math.atan2(dy_w, dx_w)) % 360.0 if not self._rect_rot_calibrated: self._rect_rot_angle_offset = ( panel.brush.rect_angle - polar) % 360.0 self._rect_rot_calibrated = True raw_angle = (polar + self._rect_rot_angle_offset) % 360.0 if ctx.shift: raw_angle = round(raw_angle / 5.0) * 5.0 panel.brush.rect_angle = raw_angle panel.sync_angle_only() viewer._update_tooltip_position() viewer._update_tooltip() self.request_brush_refresh() return True else: # R released — unlock rotation centre and do a full sync if self._rect_rot_center_xy is not None and panel is not None: panel.sync_sliders() self._rect_rot_center_xy = None self._rect_rot_calibrated = False self.cursor_xy = (x, y) viewer._update_tooltip_position() viewer._update_tooltip() if ctx.left_down: self.pressure = ctx.pressure self.sculpt_apply_at(x, y, ctx.pressure) return True if not ctx.middle_down: # No button (or right only) → just refresh cursor preview self.pressure = ctx.pressure self.request_brush_refresh() return True # Middle button is down → fall through to pan code return False # ── Profile action ───────────────────────────────────────────── if viewer.action == 'profile': self.profile_cursor_xy = (x, y) if ctx.left_down and not ctx.shift: self.profile_apply_at(x, y) return True # Pre-compute ghost segment so the GL cursor is smooth pp = viewer.profile_panel if pp is not None and viewer.active_array is not None: ux, uy = pp.brush._estimate_gradient_dir(x, y, viewer.active_array) L = pp.brush.segment_length chw = pp.brush.corridor_half_width if pp.brush.click_is_foot: fx, fy = x, y hx, hy = x + ux * L, y + uy * L else: hx, hy = x, y fx, fy = x - ux * L, y - uy * L px, py = -uy, ux self.profile_last_seg = (fx, fy, hx, hy, px, py, chw) viewer._update_tooltip() self.request_brush_refresh() return True return False
[docs] def on_end_action(self, prev_action: str) -> None: """Clean up sculpt / profile state and notify panels when action ends.""" viewer = self._viewer if prev_action == 'sculpt': self.cursor_xy = None panel = viewer.sculpt_panel if panel is not None and hasattr(panel, 'notify_action_ended'): try: panel.notify_action_ended() except Exception: pass if prev_action == 'profile': self.profile_cursor_xy = None self.profile_last_seg = None pp = viewer.profile_panel if pp is not None and hasattr(pp, 'notify_action_ended'): try: pp.notify_action_ended() except Exception: pass
[docs] def on_key_z(self, ctx: KeyboardSnapshot) -> bool: """Handle Ctrl+Z undo for sculpt / profile. Returns ``True`` if the key was consumed (caller should skip the default zoom-on-array action). """ viewer = self._viewer if viewer.action == 'sculpt' and ctx.ctrl and not ctx.shift: panel = viewer.sculpt_panel if panel is not None and viewer.active_array is not None: result = panel.brush.undo(viewer.active_array) if result is not None: ii, jj = result i_min, i_max = int(ii.min()), int(ii.max()) j_min, j_max = int(jj.min()), int(jj.max()) if viewer.SetCurrentContext(): viewer.active_array.sculpt_update_patch( i_min, i_max, j_min, j_max) viewer.Refresh() return True if viewer.action == 'profile' and ctx.ctrl and not ctx.shift: pp = viewer.profile_panel if pp is not None and viewer.active_array is not None: cells = pp.brush.undo(viewer.active_array) if cells: i_min = min(ix for ix, __ in cells) i_max = max(ix for ix, __ in cells) j_min = min(iy for __, iy in cells) j_max = max(iy for __, iy in cells) if viewer.SetCurrentContext(): viewer.active_array.sculpt_update_patch( i_min, i_max, j_min, j_max) viewer.Refresh() return True return False
[docs] def on_key_r(self, ctx: KeyboardSnapshot) -> bool: """Guard: suppress reset-selection when sculpt RECTANGLE rotate mode is active. Returns ``True`` if R was suppressed; the caller is responsible for calling ``e.Skip()`` so the key event continues to propagate. """ viewer = self._viewer if ( viewer.action == 'sculpt' and getattr(getattr(viewer, 'sculpt_panel', None), 'brush', None) is not None and viewer.sculpt_panel.brush.shape == BrushShape.RECTANGLE ): return True return False
# ------------------------------------------------------------------ # # Core implementation methods # # ------------------------------------------------------------------ #
[docs] def hide_size(self) -> None: """Callback scheduled via ``viewer.schedule_once`` to hide the radius-size overlay.""" self.show_size = False self._viewer.Refresh()
[docs] def hide_zone(self) -> None: """Callback scheduled via ``viewer.schedule_once`` to hide the search-zone overlay.""" self.show_zone = False self._viewer.Refresh()
[docs] def get_event_pressure(self, e) -> float: """Return stylus pressure in [0, 1]. Reads from WinTab (Wintab32.dll) when available, which works reliably on wx.GLCanvas. Falls back to wx then to 1.0. """ wintab = self._viewer._wintab if wintab is not None: p = wintab.get_pressure() if 0.0 <= p <= 1.0: return p try: p = e.GetPressure() if 0.0 < p <= 1.0: return float(p) except AttributeError: pass return 1.0
[docs] def profile_apply_at(self, x: float, y: float) -> None: """Apply the profile brush at world position (x, y).""" viewer = self._viewer panel = viewer.profile_panel if panel is None or viewer.active_array is None: return changes = panel.brush.apply(viewer.active_array, x, y) if changes: i_min = min(ix for ix, __, ___, ____ in changes) i_max = max(ix for ix, __, ___, ____ in changes) j_min = min(iy for __, iy, ___, ____ in changes) j_max = max(iy for __, iy, ___, ____ in changes) if viewer.SetCurrentContext(): viewer.active_array.sculpt_update_patch(i_min, i_max, j_min, j_max) pp = panel ux, uy = pp.brush._estimate_gradient_dir(x, y, viewer.active_array) L = pp.brush.segment_length chw = pp.brush.corridor_half_width if pp.brush.click_is_foot: fx, fy = x, y hx, hy = x + ux * L, y + uy * L else: hx, hy = x, y fx, fy = x - ux * L, y - uy * L px, py = -uy, ux self.profile_last_seg = (fx, fy, hx, hy, px, py, chw) viewer._cutfill_overlay.invalidate() self.request_brush_refresh()
[docs] def request_brush_refresh(self) -> None: """Schedule a single Refresh() for the next event-loop idle. Multiple calls from the same burst of mouse-motion events all land here; only the *first* actually posts a deferred call so that one repaint serves all pending GPU patches. """ if not self.brush_refresh_pending: self.brush_refresh_pending = True self._viewer.post_idle(self.do_brush_refresh)
[docs] def do_brush_refresh(self) -> None: """Callback — executed once per burst, repaints the canvas.""" self.brush_refresh_pending = False self._viewer.Refresh()
[docs] def sculpt_apply_at(self, x: float, y: float, pressure: float = 1.0) -> None: """Apply the sculpt brush at world position (x, y). *pressure* is a stylus pressure value in [0, 1]. """ viewer = self._viewer panel = viewer.sculpt_panel if panel is None or viewer.active_array is None: return result = panel.brush.apply(viewer.active_array, x, y, pressure=pressure) if result is not None: ii, jj = result i_min, i_max = int(ii.min()), int(ii.max()) j_min, j_max = int(jj.min()), int(jj.max()) if viewer.SetCurrentContext(): viewer.active_array.sculpt_update_patch(i_min, i_max, j_min, j_max) viewer._cutfill_overlay.invalidate() self.request_brush_refresh()
[docs] def draw_profile_cursor(self) -> None: """Draw the profile brush ghost overlay on the OpenGL canvas.""" if self.profile_cursor_xy is None: return import math as _math from OpenGL.GL import ( GL_BLEND, GL_DEPTH_TEST, GL_LINE_LOOP, GL_LINE_SMOOTH, GL_LINES, GL_ONE_MINUS_SRC_ALPHA, GL_POINTS, GL_SRC_ALPHA, glBegin, glBlendFunc, glColor4f, glDisable, glEnable, glEnd, glIsEnabled, glLineWidth, glPointSize, glVertex2f, ) viewer = self._viewer fp = viewer.profile_panel seg = self.profile_last_seg cx_m, cy_m = self.profile_cursor_xy was_depth = bool(glIsEnabled(GL_DEPTH_TEST)) was_blend = bool(glIsEnabled(GL_BLEND)) glDisable(GL_DEPTH_TEST) glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) glEnable(GL_LINE_SMOOTH) # ── 1. Gradient-search-radius circle ────────────────────────── r_grad = fp.brush.gradient_search_radius if fp is not None else 0.0 has_eik = fp is not None and fp.brush._eikonal_dir_cache is not None circ_color = (0.35, 0.90, 0.45, 0.75) if has_eik else (1.00, 0.75, 0.20, 0.70) if r_grad > 0.0: N_DASH = 48 glLineWidth(2.5) glColor4f(0.0, 0.0, 0.0, 0.30) glBegin(GL_LINES) for k in range(0, N_DASH, 2): a0 = 2.0 * _math.pi * k / N_DASH a1 = 2.0 * _math.pi * (k + 1) / N_DASH glVertex2f(cx_m + r_grad * _math.cos(a0), cy_m + r_grad * _math.sin(a0)) glVertex2f(cx_m + r_grad * _math.cos(a1), cy_m + r_grad * _math.sin(a1)) glEnd() glLineWidth(1.4) glColor4f(*circ_color) glBegin(GL_LINES) for k in range(0, N_DASH, 2): a0 = 2.0 * _math.pi * k / N_DASH a1 = 2.0 * _math.pi * (k + 1) / N_DASH glVertex2f(cx_m + r_grad * _math.cos(a0), cy_m + r_grad * _math.sin(a0)) glVertex2f(cx_m + r_grad * _math.cos(a1), cy_m + r_grad * _math.sin(a1)) glEnd() if seg is None: if not was_depth: glDisable(GL_DEPTH_TEST) if not was_blend: glDisable(GL_BLEND) return fx, fy, hx, hy, px, py, chw = seg click_foot = fp.brush.click_is_foot if fp is not None else True click_x = fx if click_foot else hx click_y = fy if click_foot else hy other_x = hx if click_foot else fx other_y = hy if click_foot else fy # ── 2. Corridor outline (teal) ──────────────────────────────── c_x0, c_y0 = fx + px * chw, fy + py * chw c_x1, c_y1 = fx - px * chw, fy - py * chw c_x2, c_y2 = hx + px * chw, hy + py * chw c_x3, c_y3 = hx - px * chw, hy - py * chw glLineWidth(3.2) glColor4f(0.0, 0.0, 0.0, 0.40) glBegin(GL_LINE_LOOP) glVertex2f(c_x0, c_y0); glVertex2f(c_x2, c_y2) glVertex2f(c_x3, c_y3); glVertex2f(c_x1, c_y1) glEnd() glBegin(GL_LINES) glVertex2f(fx, fy); glVertex2f(hx, hy) glEnd() glLineWidth(1.8) glColor4f(0.20, 0.85, 0.80, 0.95) glBegin(GL_LINE_LOOP) glVertex2f(c_x0, c_y0); glVertex2f(c_x2, c_y2) glVertex2f(c_x3, c_y3); glVertex2f(c_x1, c_y1) glEnd() mx, my = (fx + hx) * 0.5, (fy + hy) * 0.5 glBegin(GL_LINES) glVertex2f(fx, fy); glVertex2f(mx, my) glVertex2f(mx, my); glVertex2f(hx, hy) glEnd() # ── 3. Gradient direction indicator ────────────────────────── L_seg = _math.hypot(hx - fx, hy - fy) arr_len = min(r_grad * 0.35, L_seg * 0.45) if r_grad > 0.0 else L_seg * 0.30 if arr_len > 0.0 and L_seg > 1e-9: ux = (other_x - click_x) / L_seg uy = (other_y - click_y) / L_seg ax, ay = click_x + ux * arr_len, click_y + uy * arr_len glLineWidth(2.8) glColor4f(0.0, 0.0, 0.0, 0.35) glBegin(GL_LINES) glVertex2f(click_x, click_y); glVertex2f(ax, ay) glEnd() glLineWidth(1.5) glColor4f(1.00, 0.75, 0.20, 0.90) glBegin(GL_LINES) glVertex2f(click_x, click_y); glVertex2f(ax, ay) glEnd() head_sz = arr_len * 0.22 a_ang = _math.atan2(ay - click_y, ax - click_x) for side in (+1, -1): bx = ax - head_sz * _math.cos(a_ang + side * 0.45) by = ay - head_sz * _math.sin(a_ang + side * 0.45) glBegin(GL_LINES) glVertex2f(ax, ay); glVertex2f(bx, by) glEnd() # ── 4. Click-point marker (yellow) ─────────────────────────── glPointSize(8.0) glColor4f(1.0, 1.0, 0.3, 1.0) glBegin(GL_POINTS) glVertex2f(click_x, click_y) glEnd() glPointSize(1.0) # ── 5. HUD text label ───────────────────────────────────────── try: from .opengl.text_renderer2d import TextRenderer2D tr = TextRenderer2D.get_instance() mvp = viewer.get_ortho_mvp_c_contiguous() if mvp is not None: w_px, h_px = viewer.canvas.GetSize() label = _('L={:.1f} m h={:.1f} m').format( fp.brush.segment_length if fp is not None else 0.0, fp.brush.bank_height if fp is not None else 0.0, ) if has_eik: label += ' [eikonal \u2713]' perp_offset = chw * 0.15 + r_grad * 0.04 tr.draw_text( label, mx, my + perp_offset, mvp, (int(w_px), int(h_px)), font_size=13.0, color=(0.20, 0.85, 0.80, 1.0), size_in_pixels=True, alignment='center', vertical_alignment='bottom', glow_enabled=True, glow_width=0.15, glow_color=(0.0, 0.0, 0.0, 0.85), ) except Exception: pass if not was_depth: glDisable(GL_DEPTH_TEST) if not was_blend: glDisable(GL_BLEND)
[docs] def draw_sculpt_cursor(self) -> None: """Draw the sculpt brush footprint (circle / square / rectangle).""" if self.cursor_xy is None: return panel = self._viewer.sculpt_panel if panel is None: return from OpenGL.GL import ( GL_BLEND, GL_DEPTH_TEST, GL_LINE_LOOP, GL_LINE_SMOOTH, GL_LINE_STRIP, GL_LINES, GL_ONE_MINUS_SRC_ALPHA, GL_SRC_ALPHA, GL_TRIANGLES, glBegin, glBlendFunc, glColor4f, glDisable, glEnable, glEnd, glIsEnabled, glLineWidth, glVertex2f, ) viewer = self._viewer cx, cy = self.cursor_xy r = panel.brush.radius _MODE_COLORS = { SculptMode.SMOOTH: (0.40, 0.70, 1.00, 0.95), SculptMode.RAISE: (0.30, 0.90, 0.40, 0.95), SculptMode.LOWER: (1.00, 0.50, 0.10, 0.95), SculptMode.FLATTEN: (1.00, 0.95, 0.30, 0.95), SculptMode.FLATTEN_PLANE: (0.96, 0.78, 0.26, 0.95), SculptMode.NOISE: (0.80, 0.40, 1.00, 0.95), } mode_color = _MODE_COLORS.get(panel.brush.mode, (1.0, 1.0, 1.0, 0.90)) p = float(self.pressure) line_w = 1.5 + p * 2.5 was_depth = bool(glIsEnabled(GL_DEPTH_TEST)) was_blend = bool(glIsEnabled(GL_BLEND)) glDisable(GL_DEPTH_TEST) glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) glEnable(GL_LINE_SMOOTH) shape = panel.brush.shape def _draw_brush_outline(lw: float, color: tuple) -> None: glLineWidth(lw) glColor4f(*color) if shape == BrushShape.CIRCLE: N = 64 glBegin(GL_LINE_LOOP) for k in range(N): a = 2.0 * math.pi * k / N glVertex2f(cx + r * math.cos(a), cy + r * math.sin(a)) glEnd() elif shape == BrushShape.RECTANGLE: _hw = panel.brush.rect_width _hh = panel.brush.rect_height _ang = math.radians(panel.brush.rect_angle) _ca, _sa = math.cos(_ang), math.sin(_ang) glBegin(GL_LINE_LOOP) for _lx, _ly in ((-_hw, -_hh), (_hw, -_hh), (_hw, _hh), (-_hw, _hh)): glVertex2f(cx + _lx * _ca - _ly * _sa, cy + _lx * _sa + _ly * _ca) glEnd() else: # SQUARE glBegin(GL_LINE_LOOP) glVertex2f(cx - r, cy - r) glVertex2f(cx + r, cy - r) glVertex2f(cx + r, cy + r) glVertex2f(cx - r, cy + r) glEnd() # Dark halo then coloured stroke _draw_brush_outline(line_w + 1.5, (0.0, 0.0, 0.0, 0.40)) _draw_brush_outline(line_w, mode_color) # Small cross at centre cross = r * 0.06 glLineWidth(max(1.0, line_w - 1.0)) glColor4f(*mode_color) glBegin(GL_LINES) glVertex2f(cx - cross, cy); glVertex2f(cx + cross, cy) glVertex2f(cx, cy - cross); glVertex2f(cx, cy + cross) glEnd() # ── Zone overlay (shown briefly after ALT+SHIFT+wheel) ──────── if self.show_zone: _needs_zone_draw = ( (panel.brush.mode == SculptMode.FLATTEN and panel.brush.flatten_auto) or (panel.brush.mode == SculptMode.FLATTEN_PLANE and panel.brush.plane_auto) ) if _needs_zone_draw: sr_label = panel.brush.search_zone_factor * r glColor4f(1.0, 0.85, 0.2, 0.95) glLineWidth(1.5) glBegin(GL_LINES) glVertex2f(cx, cy) glVertex2f(cx + sr_label, cy) glEnd() tick = sr_label * 0.04 glBegin(GL_LINES) glVertex2f(cx + sr_label, cy - tick) glVertex2f(cx + sr_label, cy + tick) glEnd() try: from .opengl.text_renderer2d import TextRenderer2D tr = TextRenderer2D.get_instance() mvp = viewer.get_ortho_mvp_c_contiguous() if mvp is not None: w_px, h_px = viewer.canvas.GetSize() label = ( f'\u2205 zone {sr_label * 2.0:.1f} m' f' (\xd7{panel.brush.search_zone_factor:.2g})' ) tr.draw_text( label, cx + sr_label * 0.5, cy + sr_label * 0.04, mvp, (int(w_px), int(h_px)), font_size=14.0, color=(1.0, 0.85, 0.2, 1.0), size_in_pixels=True, alignment='center', vertical_alignment='bottom', glow_enabled=False, glow_width=0.15, glow_color=(0.0, 0.0, 0.0, 0.8), ) except Exception: pass # ── Size overlay (shown briefly after SHIFT+wheel) ──────────── if self.show_size: glColor4f(1.0, 0.85, 0.2, 0.95) glLineWidth(1.5) glBegin(GL_LINES) glVertex2f(cx, cy) glVertex2f(cx + r, cy) glEnd() tick = r * 0.04 glBegin(GL_LINES) glVertex2f(cx + r, cy - tick) glVertex2f(cx + r, cy + tick) glEnd() try: from .opengl.text_renderer2d import TextRenderer2D tr = TextRenderer2D.get_instance() mvp = viewer.get_ortho_mvp_c_contiguous() if mvp is not None: w_px, h_px = viewer.canvas.GetSize() label = f'\u00f8 {r * 2.0:.1f} m' tr.draw_text( label, cx + r * 0.5, cy + r * 0.04, mvp, (int(w_px), int(h_px)), font_size=14.0, color=(1.0, 0.85, 0.2, 1.0), size_in_pixels=True, alignment='center', vertical_alignment='bottom', glow_enabled=False, glow_width=0.15, glow_color=(0.0, 0.0, 0.0, 0.8), ) except Exception: pass # ── Search-zone dashed ring for auto-flatten modes ──────────── _needs_zone = ( (panel.brush.mode == SculptMode.FLATTEN and panel.brush.flatten_auto) or (panel.brush.mode == SculptMode.FLATTEN_PLANE and panel.brush.plane_auto) ) if _needs_zone: sr = panel.brush.search_zone_factor * r N_z = 48 _dstep = 3 mc = mode_color glLineWidth(3.0) glColor4f(0.0, 0.0, 0.0, 0.45) for _k in range(0, N_z, 2 * _dstep): glBegin(GL_LINE_STRIP) for _m in range(_dstep + 1): _a = 2.0 * math.pi * (_k + _m) / N_z glVertex2f(cx + sr * math.cos(_a), cy + sr * math.sin(_a)) glEnd() glLineWidth(1.5) glColor4f(mc[0], mc[1], mc[2], 0.70) for _k in range(0, N_z, 2 * _dstep): glBegin(GL_LINE_STRIP) for _m in range(_dstep + 1): _a = 2.0 * math.pi * (_k + _m) / N_z glVertex2f(cx + sr * math.cos(_a), cy + sr * math.sin(_a)) glEnd() # ── Slope arrow for FLATTEN_PLANE mode ──────────────────────── if panel.brush.mode == SculptMode.FLATTEN_PLANE: _sx = panel.brush.plane_slope_x _sy = panel.brush.plane_slope_y _mag = math.hypot(_sx, _sy) if _mag > 1e-9: mc = mode_color _alen = r * 0.45 _ex = cx + _alen * _sx / _mag _ey = cy + _alen * _sy / _mag glLineWidth(1.8) glColor4f(mc[0], mc[1], mc[2], 0.90) glBegin(GL_LINES) glVertex2f(cx, cy) glVertex2f(_ex, _ey) glEnd() _ah = r * 0.07 _px = -_sy / _mag * _ah _py = _sx / _mag * _ah _dx = _sx / _mag * _ah _dy = _sy / _mag * _ah glBegin(GL_TRIANGLES) glVertex2f(_ex, _ey) glVertex2f(_ex - _dx + _px, _ey - _dy + _py) glVertex2f(_ex - _dx - _px, _ey - _dy - _py) glEnd() glLineWidth(1.0) if not was_blend: glDisable(GL_BLEND) if was_depth: glEnable(GL_DEPTH_TEST)