Source code for wolfhece.opengl.toolbar_icons

"""Procedural icon texture atlas for the on-canvas ToolbarOverlay.

Each icon is rasterised with Pillow *ImageDraw* into a 64 × 64 RGBA cell.
All cells are packed into a single OpenGL texture uploaded once on first use.
Icons are drawn as textured quads tinted by ``glColor4f``.
"""
from __future__ import annotations

import math
from PIL import Image, ImageDraw
import numpy as np

# ── Cell / atlas geometry ────────────────────────────────────────────
[docs] _CELL = 64
[docs] _PAD = 12
[docs] _LW = 3
# ── Colours used in icon drawing ─────────────────────────────────────
[docs] _W = (235, 235, 240, 255) # primary white
[docs] _G = (160, 160, 170, 180) # accent gray
# ── Drawing helpers ──────────────────────────────────────────────────
[docs] def _plus(d: ImageDraw.ImageDraw, cx, cy, r=8, w=_LW, fill=_W): d.line([(cx - r, cy), (cx + r, cy)], fill=fill, width=w) d.line([(cx, cy - r), (cx, cy + r)], fill=fill, width=w)
[docs] def _cross(d: ImageDraw.ImageDraw, cx, cy, r=8, w=_LW, fill=_W): d.line([(cx - r, cy - r), (cx + r, cy + r)], fill=fill, width=w) d.line([(cx + r, cy - r), (cx - r, cy + r)], fill=fill, width=w)
[docs] def _dot(d: ImageDraw.ImageDraw, x, y, r=3, fill=_W): d.ellipse([x - r, y - r, x + r, y + r], fill=fill)
[docs] def _arrowhead(d: ImageDraw.ImageDraw, x1, y1, x2, y2, size=7, fill=_W): """Arrowhead at (*x2*, *y2*) coming from (*x1*, *y1*).""" angle = math.atan2(y2 - y1, x2 - x1) a1 = angle + 2.5 a2 = angle - 2.5 pts = [ (x2, y2), (x2 + size * math.cos(a1), y2 + size * math.sin(a1)), (x2 + size * math.cos(a2), y2 + size * math.sin(a2)), ] d.polygon([(int(round(px)), int(round(py))) for px, py in pts], fill=fill)
[docs] def _arrow(d: ImageDraw.ImageDraw, x1, y1, x2, y2, head=7, w=_LW, fill=_W): """Line with arrowhead.""" d.line([(x1, y1), (x2, y2)], fill=fill, width=w) _arrowhead(d, x1, y1, x2, y2, size=head, fill=fill)
# ── Icon drawing functions ─────────────────────────────────────────── # Each: fn(draw, s, p) where s = cell size (64), p = padding (12). # Usable area: (p, p) → (s−p, s−p), centre at (s//2, s//2).
[docs] def _icon_add_zone(d, s, p): """Rectangle with centred + sign.""" d.rectangle([p, p, s - p, s - p], outline=_W, width=_LW) _plus(d, s // 2, s // 2)
[docs] def _icon_add_vec(d, s, p): """Polyline with vertex dots and + badge.""" pts = [(p + 2, s - p - 2), (p + 10, p + 4), (s - p - 10, s // 2 + 4), (s - p - 2, p + 2)] d.line(pts, fill=_W, width=_LW) for x, y in pts: _dot(d, x, y, 3) _plus(d, s - p - 6, s - p - 6, r=5)
[docs] def _icon_capture(d, s, p): """Pen / pencil diagonal.""" # Body d.line([(s - p - 2, p + 2), (p + 8, s - p - 8)], fill=_W, width=4) # Nib d.polygon([(p + 2, s - p - 2), (p + 8, s - p - 8), (p + 12, s - p - 4)], fill=_W) # Dots already placed _dot(d, s - p - 6, s // 2 + 2, 2) _dot(d, s // 2 + 4, s - p - 8, 2)
[docs] def _icon_modify(d, s, p): """Vertex dot with 4-way move arrows.""" m = s // 2 _dot(d, m, m, 5) r = 16 for dx, dy in [(1, 0), (-1, 0), (0, 1), (0, -1)]: _arrow(d, m + dx * 7, m + dy * 7, m + dx * r, m + dy * r, head=5, w=2)
[docs] def _icon_insert(d, s, p): """Line with new vertex inserted in the middle.""" y = s // 2 d.line([(p, y), (s - p, y)], fill=_W, width=_LW) _dot(d, p + 4, y, 3) _dot(d, s - p - 4, y, 3) _dot(d, s // 2, y, 5) _plus(d, s // 2, y - 14, r=5)
[docs] def _icon_move_vec(d, s, p): """Four-way arrow cross (standard move icon).""" m = s // 2 r = m - p - 2 for dx, dy in [(1, 0), (-1, 0), (0, 1), (0, -1)]: _arrow(d, m, m, m + dx * r, m + dy * r, head=7)
[docs] def _icon_rotate(d, s, p): """Circular arrow (arc + arrowhead).""" m = s // 2 r = m - p - 4 # Draw arc as polyline segments (0° → 280°) pts = [] for deg in range(0, 281, 8): a = math.radians(deg) pts.append((m + r * math.cos(a), m + r * math.sin(a))) d.line(pts, fill=_W, width=_LW) # Arrowhead at the end if len(pts) >= 2: _arrowhead(d, pts[-2][0], pts[-2][1], pts[-1][0], pts[-1][1], size=8)
[docs] def _icon_parallel(d, s, p): """Two parallel horizontal lines.""" y1 = s // 2 - 9 y2 = s // 2 + 9 d.line([(p + 2, y1), (s - p - 2, y1)], fill=_W, width=_LW) d.line([(p + 2, y2), (s - p - 2, y2)], fill=_W, width=_LW) # Vertical ticks showing equal spacing for x in [s // 3, 2 * s // 3]: d.line([(x, y1 + 3), (x, y2 - 3)], fill=_G, width=1)
[docs] def _icon_sel_poly(d, s, p): """Selection polygon (pentagon outline).""" m = s // 2 pts = [(m, p + 2), (s - p - 2, m - 6), (s - p - 6, s - p - 2), (p + 6, s - p - 2), (p + 2, m - 6)] d.line(pts + [pts[0]], fill=_W, width=2) # Dashed interior hint _dot(d, m, m + 4, 2, fill=_G)
[docs] def _icon_sel_line(d, s, p): """Zigzag polyline (select along path).""" pts = [(p, s // 2 + 10), (s // 4, s // 2 - 10), (s // 2, s // 2 + 10), (3 * s // 4, s // 2 - 10), (s - p, s // 2 + 10)] d.line(pts, fill=_W, width=_LW)
[docs] def _icon_sel_all(d, s, p): """Filled square = select all.""" d.rectangle([p + 4, p + 4, s - p - 4, s - p - 4], fill=_W)
[docs] def _icon_reset_sel(d, s, p): """Bold X cross = reset / clear.""" r = s // 2 - p _cross(d, s // 2, s // 2, r=r, w=4)
[docs] def _icon_dilate(d, s, p): """Small square with outward arrows at corners.""" m = s // 2 r = 7 d.rectangle([m - r, m - r, m + r, m + r], outline=_W, width=2) for dx, dy in [(-1, -1), (1, -1), (1, 1), (-1, 1)]: _arrow(d, m + dx * (r + 1), m + dy * (r + 1), m + dx * (r + 11), m + dy * (r + 11), head=4, w=2)
[docs] def _icon_erode(d, s, p): """Inward arrows converging to small square.""" m = s // 2 r = 7 d.rectangle([m - r, m - r, m + r, m + r], outline=_W, width=2) for dx, dy in [(-1, -1), (1, -1), (1, 1), (-1, 1)]: _arrow(d, m + dx * (r + 11), m + dy * (r + 11), m + dx * (r + 1), m + dy * (r + 1), head=4, w=2)
[docs] def _icon_mask_in(d, s, p): """Filled triangle = mask inside.""" pts = [(s // 2, p + 4), (s - p - 4, s - p - 4), (p + 4, s - p - 4)] d.polygon(pts, fill=_W)
[docs] def _icon_mask_out(d, s, p): """Rectangle with triangular hole = mask outside.""" d.rectangle([p, p, s - p, s - p], fill=_G) # Punch a triangle hole (write transparent pixels) pts = [(s // 2, p + 10), (s - p - 10, s - p - 10), (p + 10, s - p - 10)] d.polygon(pts, fill=(0, 0, 0, 0)) d.line(pts + [pts[0]], fill=_W, width=2)
[docs] def _icon_add_pts(d, s, p): """Scattered dots with + badge.""" positions = [(p + 8, p + 12), (s // 2 + 6, p + 10), (s - p - 6, s // 2), (p + 10, s // 2 + 8), (s // 2 - 4, s - p - 10)] for x, y in positions: _dot(d, x, y, 4) _plus(d, s - p - 6, s - p - 6, r=5)
[docs] def _icon_move_pt(d, s, p): """Single dot with arrow showing movement.""" _dot(d, p + 10, p + 10, 5) _arrow(d, p + 16, p + 16, s - p - 6, s - p - 6, head=7)
[docs] def _icon_profile(d, s, p): """Crosshair / target (select profile).""" m = s // 2 r = m - p - 2 _plus(d, m, m, r=r, w=2) d.ellipse([m - 8, m - 8, m + 8, m + 8], outline=_W, width=2)
[docs] def _icon_plot_cs(d, s, p): """Mini axes with curve (plot cross section).""" # Axes (L-shape) d.line([(p + 6, p + 6), (p + 6, s - p - 4), (s - p - 4, s - p - 4)], fill=_W, width=2) # Curve pts = [(p + 10, s - p - 12), (s // 3, p + 10), (2 * s // 3, s // 2), (s - p - 8, p + 8)] d.line(pts, fill=_W, width=_LW)
[docs] def _icon_pick_tile(d, s, p): """2×2 grid (pick tile).""" m = s // 2 g = 3 d.rectangle([p, p, m - g, m - g], outline=_W, width=2) d.rectangle([m + g, p, s - p, m - g], outline=_W, width=2) d.rectangle([p, m + g, m - g, s - p], outline=_W, width=2) d.rectangle([m + g, m + g, s - p, s - p], outline=_W, width=2)
[docs] def _icon_sculpt(d, s, p): """Brush circle with terrain hill inside (open sculpt panel).""" m = s // 2 r = m - p - 3 d.ellipse([m - r, m - r, m + r, m + r], outline=_W, width=2) # Falloff ring hint r2 = int(r * 0.58) d.ellipse([m - r2, m - r2, m + r2, m + r2], outline=_G, width=1) # Terrain hill profile pts = [(p + 8, m + 7), (m - 10, m - 5), (m, m - 13), (m + 10, m - 5), (s - p - 8, m + 7)] d.line(pts, fill=_W, width=_LW) # Centre dot _dot(d, m, m + 10, 2, fill=_G)
[docs] def _icon_sculpt_smooth(d, s, p): """Jagged line (gray) → smooth curve (white): smooth mode.""" # Rough "before" terrain (gray, top half) pts1 = [(p + 4, s // 2 - 2), (p + 12, s // 2 - 13), (s // 3 + 2, s // 2 + 4), (s // 2, s // 2 - 11), (2 * s // 3, s // 2 + 6), (s - p - 4, s // 2 - 4)] d.line(pts1, fill=_G, width=2) # Smooth "after" terrain (white, same path but smoothed) pts2 = [(p + 4, s // 2 + 6), (s // 4, s // 2 + 2), (s // 2, s // 2 - 2), (3 * s // 4, s // 2 + 2), (s - p - 4, s // 2 + 6)] d.line(pts2, fill=_W, width=_LW)
[docs] def _icon_sculpt_raise(d, s, p): """Baseline + bump with upward arrow: raise mode.""" y_base = s - p - 10 d.line([(p + 6, y_base), (s - p - 6, y_base)], fill=_G, width=2) pts = [(p + 8, y_base), (s // 3, y_base - 14), (s // 2, y_base - 22), (2 * s // 3, y_base - 14), (s - p - 8, y_base)] d.line(pts, fill=_W, width=_LW) _arrow(d, s // 2, y_base - 24, s // 2, p + 6, head=6, w=2)
[docs] def _icon_sculpt_lower(d, s, p): """Baseline + hollow with downward arrow: lower/dig mode.""" y_base = p + 10 d.line([(p + 6, y_base), (s - p - 6, y_base)], fill=_G, width=2) pts = [(p + 8, y_base), (s // 3, y_base + 14), (s // 2, y_base + 22), (2 * s // 3, y_base + 14), (s - p - 8, y_base)] d.line(pts, fill=_W, width=_LW) _arrow(d, s // 2, y_base + 24, s // 2, s - p - 6, head=6, w=2)
[docs] def _icon_sculpt_flatten(d, s, p): """Irregular terrain + horizontal target line: flatten mode.""" # Irregular profile (gray) pts1 = [(p + 4, s // 2 + 8), (p + 14, s // 2 - 12), (s // 3, s // 2 + 6), (s // 2, s // 2 - 14), (2 * s // 3, s // 2 + 10), (s - p - 4, s // 2 - 4)] d.line(pts1, fill=_G, width=2) # Target flat line (white, bold) y_flat = s // 2 d.line([(p + 4, y_flat), (s - p - 4, y_flat)], fill=_W, width=_LW) # Small down-arrows onto the line at three positions for xi in [s // 4, s // 2, 3 * s // 4]: d.line([(xi, y_flat - 10), (xi, y_flat - 3)], fill=_W, width=1) _arrowhead(d, xi, y_flat - 10, xi, y_flat - 3, size=4, fill=_W)
[docs] def _icon_sculpt_noise(d, s, p): """Jagged random terrain + sparkle: noise mode.""" pts = [(p + 4, s // 2 + 2), (p + 11, s // 2 - 13), (p + 21, s // 2 + 11), (p + 29, s // 2 - 10), (p + 37, s // 2 + 13), (s - p - 8, s // 2 - 7), (s - p - 4, s // 2 + 2)] d.line(pts, fill=_W, width=_LW) # Sparkle in top-right corner cx, cy = s - p - 8, p + 10 for ang in range(0, 360, 45): a = math.radians(ang) d.line([(int(cx), int(cy)), (int(cx + 6 * math.cos(a)), int(cy + 6 * math.sin(a)))], fill=_G, width=1) _dot(d, cx, cy, 2, fill=_W)
[docs] def _icon_profile_brush(d, s, p): """Profile brush: plan-view corridor+arrow (left) | cross-section slope (right).""" m = s // 2 # ── Left half: plan view ────────────────────────────────────────── # Corridor: two dashed parallel lines above/below the axis for sign in (-1, 1): yo = m + sign * 7 for x0, x1 in [(p + 4, p + 13), (p + 17, p + 26), (p + 30, m - 3)]: d.line([(x0, yo), (x1, yo)], fill=_G, width=1) # Segment axis arrow (foot → head) _arrow(d, p + 5, m, m - 4, m, head=6, w=2) # Foot dot _dot(d, p + 7, m, 3, fill=_W) # Vertical separator d.line([(m + 1, p + 6), (m + 1, s - p - 6)], fill=_G, width=1) # ── Right half: cross-section profile ──────────────────────────── # Mini coordinate axes (L-shape) ax0, ay0 = m + 5, s - p - 6 # origin (bottom-left of axes) ax1_h = s - p - 4 # horizontal axis end ay1_v = p + 6 # vertical axis end d.line([(ax0, ay0), (ax0, ay1_v)], fill=_G, width=1) d.line([(ax0, ay0), (ax1_h, ay0)], fill=_G, width=1) # Slope line (1:2 slope — gentle) x_head = ax1_h - 2 y_head = ay1_v + (ay0 - ay1_v) // 3 d.line([(ax0 + 1, ay0 - 1), (x_head, y_head)], fill=_W, width=_LW) # Small tick at head _dot(d, x_head, y_head, 2, fill=_W)
# ── Registry ─────────────────────────────────────────────────────────
[docs] _DRAW_FNS: dict[str, object] = { 'add_zone': _icon_add_zone, 'add_vec': _icon_add_vec, 'capture': _icon_capture, 'modify': _icon_modify, 'insert': _icon_insert, 'move_vec': _icon_move_vec, 'rotate': _icon_rotate, 'parallel': _icon_parallel, 'sel_poly': _icon_sel_poly, 'sel_line': _icon_sel_line, 'sel_all': _icon_sel_all, 'reset_sel': _icon_reset_sel, 'dilate': _icon_dilate, 'erode': _icon_erode, 'mask_in': _icon_mask_in, 'mask_out': _icon_mask_out, 'add_pts': _icon_add_pts, 'move_pt': _icon_move_pt, 'profile': _icon_profile, 'plot_cs': _icon_plot_cs, 'pick_tile': _icon_pick_tile, # Sculpting tools 'sculpt': _icon_sculpt, 'sculpt_smooth': _icon_sculpt_smooth, 'sculpt_raise': _icon_sculpt_raise, 'sculpt_lower': _icon_sculpt_lower, 'sculpt_flatten': _icon_sculpt_flatten, 'sculpt_noise': _icon_sculpt_noise, # Profile brush 'profile_brush': _icon_profile_brush, }
[docs] ALL_ICONS: list[str] = list(_DRAW_FNS.keys())
# ── Texture atlas singleton ──────────────────────────────────────────
[docs] class IconAtlas: """Singleton that builds and manages a GL texture atlas of toolbar icons."""
[docs] _instance: "IconAtlas | None" = None
def __init__(self):
[docs] self._tex_id: int = 0
[docs] self._uvs: dict[str, tuple[float, float, float, float]] = {}
[docs] self._ready: bool = False
@classmethod
[docs] def get_instance(cls) -> "IconAtlas": if cls._instance is None: cls._instance = cls() return cls._instance
# ── Build ────────────────────────────────────────────────────────
[docs] def ensure_ready(self): if not self._ready: try: self._build() except Exception: pass self._ready = True
[docs] def _build(self): cols = 8 n = len(ALL_ICONS) rows = (n + cols - 1) // cols aw, ah = cols * _CELL, rows * _CELL atlas = Image.new('RGBA', (aw, ah), (0, 0, 0, 0)) for i, name in enumerate(ALL_ICONS): cell = Image.new('RGBA', (_CELL, _CELL), (0, 0, 0, 0)) draw = ImageDraw.Draw(cell) fn = _DRAW_FNS.get(name) if fn is not None: fn(draw, _CELL, _PAD) c, r = i % cols, i // cols atlas.paste(cell, (c * _CELL, r * _CELL)) u0 = c / cols u1 = (c + 1) / cols v0 = 1.0 - (r + 1) / rows # bottom in GL v1 = 1.0 - r / rows # top in GL self._uvs[name] = (u0, v0, u1, v1) # Upload to OpenGL from OpenGL.GL import ( glGenTextures, glBindTexture, glTexImage2D, glTexParameteri, GL_TEXTURE_2D, GL_RGBA, GL_UNSIGNED_BYTE, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER, GL_LINEAR, GL_TEXTURE_WRAP_S, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE, ) self._tex_id = int(glGenTextures(1)) glBindTexture(GL_TEXTURE_2D, self._tex_id) # Flip Y for OpenGL (bottom-left origin) raw = np.ascontiguousarray(np.array(atlas, dtype=np.uint8)[::-1]) glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, aw, ah, 0, GL_RGBA, GL_UNSIGNED_BYTE, raw) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) glBindTexture(GL_TEXTURE_2D, 0)
# ── Render one icon ──────────────────────────────────────────────
[docs] def draw_icon(self, name: str, x0: float, y0: float, x1: float, y1: float, color: tuple = (1.0, 1.0, 1.0, 1.0)): """Render *name* as a textured quad at GL pixel coords.""" self.ensure_ready() uv = self._uvs.get(name) if uv is None: return from OpenGL.GL import ( glEnable, glDisable, glBindTexture, glColor4f, glBegin, glEnd, glVertex2f, glTexCoord2f, GL_TEXTURE_2D, GL_QUADS, ) u0, v0, u1, v1 = uv glEnable(GL_TEXTURE_2D) glBindTexture(GL_TEXTURE_2D, self._tex_id) glColor4f(*color) glBegin(GL_QUADS) glTexCoord2f(u0, v0); glVertex2f(x0, y0) glTexCoord2f(u1, v0); glVertex2f(x1, y0) glTexCoord2f(u1, v1); glVertex2f(x1, y1) glTexCoord2f(u0, v1); glVertex2f(x0, y1) glEnd() glBindTexture(GL_TEXTURE_2D, 0) glDisable(GL_TEXTURE_2D)