"""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:
# 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)