"""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 ────────────────────────────────────────────
# ── 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._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)