# OpenGL renderer infrastructure for the rs274 G-code preview. # # This module holds the low-level, reusable GL building blocks for the # preview renderer that replaces the legacy fixed-function drawing in # glcanon.py: shader compile/link helpers with proper error reporting, thin # VBO/VAO wrappers, a per-pass glGetError debug check, and the line shader # (position + color + line-number, driven by a single MVP uniform). The # glyph-atlas overlay text, at the end of the file, is the same kind of # thing: a shader, a texture and a dynamic VBO whose lifetimes this module # owns. # # COMPATIBILITY LEVEL: the target is the *intersection* of OpenGL 3.3 core # profile and OpenGL ES 3.1 - roughly GLES 3.0 feature level plus explicit # attribute locations. That is VAOs/VBOs, FBOs with multiple render targets, # textures, uniforms and uniform arrays, `flat` interpolation, instancing, # and GLSL with `layout(location=)` on inputs and outputs. # # Nothing here may use a feature exclusive to either side. Excluded from # GLES 3.1 (and so from this module even though desktop GL 4.3+ has them): # compute shaders, SSBOs, `layout(binding = N)` in GLSL. Excluded from # desktop GL: glPolygonMode, gl_ClipDistance, 1D textures, and reading the # depth buffer with glReadPixels(GL_DEPTH_COMPONENT) - which is why the pick # pass carries depth in a second colour attachment on both APIs. # # Which API is live is detected once into :class:`GLCaps` and read from # there; the GLSL version directive is injected at compile time by # :func:`_glsl` rather than written into the shader sources, so the shader # bodies are byte-identical between the two APIs. # # It deliberately contains no GlCanonDraw policy and no numpy geometry baking # (that lives in glcanon_bake.py, which stays GL-free and unit-testable). The # split keeps this module the sole owner of GL object lifetimes. # # This program is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation; either version 2 of the License, or (at your option) # any later version. from __future__ import annotations import ctypes import ctypes.util import logging import os from contextlib import contextmanager from dataclasses import dataclass from typing import Any, Callable, Iterator, Sequence from OpenGL.GL import * import numpy as np import numpy.typing as npt from rs274.glcanon_bake import (ATTR_DTYPE, FLOATS_PER_VERTEX, KIND_MASK, LineRanges, MeshVerts, PLANE_DTYPE, PaletteRGBA, TRAJ_FLOATS_PER_VERTEX, TrajectoryVerts, WideVerts) # PyOpenGL's enum constants are int subclasses, so this is honest rather than # decorative: it says "one of the GL_* names" where a bare ``int`` would say # nothing. GLEnum = int # GLCANON_DEBUG=1 is the preview's single verbosity switch. It does four things: # raises the ``rs274`` logger to DEBUG and gives it somewhere to write (below), # turns on the glGetError check after each pass, and turns on the scene's # drawn/gated-out bookkeeping (rs274.glcanon_scene reads this same flag). Off by # default because a glGetError round-trip stalls the pipeline. # # It replaces GLCANON_GL_DEBUG and GLCANON_SCENE_DEBUG, which are no longer read. GL_DEBUG = os.environ.get("GLCANON_DEBUG", "") not in ("", "0") #: This module's logger, matching the sibling modules' ``log`` convention. Not #: gated on GL_DEBUG: the records it carries are warnings about a context #: behaving in a way that will show up as a visual defect, which is precisely #: what a bug report needs and what nobody thinks to switch a flag on for. log = logging.getLogger(__name__) # The one place the preview configures logging, and only when explicitly asked # to. Library code normally has no business installing handlers - and none of # the rest of this package does - but most of what imports rs274 (AXIS, gremlin, # the standalone preview tools) never configures logging at all, so without this # the debug records would be unreachable in exactly the setups that produce bug # reports. Guarded on the handler list so a re-import cannot stack handlers. # # Unset, nothing here runs: the rs274 logger keeps its default level, debug and # info records are discarded for want of a handler, and warnings and errors # still reach stderr through logging's last-resort handler. # "gremlin" is in the list because the GTK preview host is part of this surface # and logs the same kind of thing; it is simply not inside the package, so the # rs274 logger would not reach it. Naming a logger that nothing imported just # creates an unused logger object. if GL_DEBUG: for _name in ("rs274", "gremlin"): _log = logging.getLogger(_name) if not _log.handlers: _log.setLevel(logging.DEBUG) _log.addHandler(logging.StreamHandler()) # --------------------------------------------------------------------------- # Interleaved per-vertex-colour layout. Each vertex is 8 float32: position(3) # color-rgba(4) lineno(1). # # The program trajectory, the dwell markers and the live backplot all draw # through the trajectory shader in the narrower 16-byte layout below instead - # this one is what is left: the transient grid/axes/extents/Hershey-label # geometry (rebuilt every frame from live view state, so a palette index would # cost more CPU than the bandwidth it saves) and the lathe-tool profile fill, # plus the landing place for any of those palette-indexed parts whose colours # overflow their palette and fall back to storing colour per vertex. # # ``WideVerts``, imported above, is this layout as a type; the column count is # rs274.glcanon_bake's, imported rather than restated, so the two modules cannot # drift into disagreeing about the stride. # --------------------------------------------------------------------------- VERTEX_STRIDE = FLOATS_PER_VERTEX * 4 # bytes, 32 ATTR_POSITION = 0 ATTR_COLOR = 1 ATTR_LINENO = 2 # (location, num_components, byte-offset-within-vertex[, gl type]) LINE_ATTRIBUTES = ( (ATTR_POSITION, 3, 0), (ATTR_COLOR, 4, 3 * 4), (ATTR_LINENO, 1, 7 * 4), ) # --------------------------------------------------------------------------- # The program trajectory's narrower layout: position(3 float32) and a uint32 # with the source line number and the draw category packed together. 16 bytes, # against 32 for the layout above - colour is not stored per vertex but looked # up from a palette indexed by the category. # # A separate GL_UNSIGNED_BYTE attribute for the category would leave the stride # at 17 bytes, which pads to 20; packing keeps it at 16. # # ``TrajectoryVerts``, imported above, is this layout as a type. # --------------------------------------------------------------------------- TRAJ_VERTEX_STRIDE = TRAJ_FLOATS_PER_VERTEX * 4 # bytes, 16 TRAJ_ATTRIBUTES = ( (ATTR_POSITION, 3, 0, GL_FLOAT), (ATTR_LINENO, 1, 3 * 4, GL_UNSIGNED_INT), ) # --------------------------------------------------------------------------- # The program array's layout: 20 bytes per vertex in two buffers rather than # one interleaved. rs274.glcanon_bake states it (PLANE_DTYPE / ATTR_DTYPE); # these are the same statement as attribute pointers. # # plane buffer position 3 x float32 12 B, per plane # attr buffer source line uint32 + kind/tool uint32 8 B, shared # # Two buffers because foam draws the same program on two planes: the transform # differs, so the positions do, while the line, kind and tool columns are the # same storage for both. It also gets the line number out of the packed word, # so it is a full uint32 and nothing has to range-check it. # --------------------------------------------------------------------------- ATTR_KINDTOOL = 4 PROGRAM_PLANE_ATTRIBUTES = ( (ATTR_POSITION, 3, 0, GL_FLOAT), ) PROGRAM_ATTR_ATTRIBUTES = ( (ATTR_LINENO, 1, 0, GL_UNSIGNED_INT), (ATTR_KINDTOOL, 1, 4, GL_UNSIGNED_INT), ) _INTEGER_ATTRIB_TYPES = (GL_BYTE, GL_UNSIGNED_BYTE, GL_SHORT, GL_UNSIGNED_SHORT, GL_INT, GL_UNSIGNED_INT) # --------------------------------------------------------------------------- # The second endpoint, for the quad-expanded (wide) line path. # # Expanding a segment to a quad needs both of its endpoints in one shader # invocation, so the same vertex buffer is bound a second time one vertex # further on. These are the locations that second binding lands in; the layouts # themselves are the ones above, re-pointed. Nothing is duplicated in memory. # # GL 3.3 core and GLES 3.0 both guarantee 16 vertex attributes; this reaches 5. # --------------------------------------------------------------------------- ATTR_POSITION_B = 5 ATTR_COLOR_B = 6 #: Per-vertex-colour (32-byte) layout, split into the two endpoints. WIDE_LINE_A_ATTRIBUTES = ( (ATTR_POSITION, 3, 0), (ATTR_COLOR, 4, 3 * 4), ) WIDE_LINE_B_ATTRIBUTES = ( (ATTR_POSITION_B, 3, 0), (ATTR_COLOR_B, 4, 3 * 4), ) #: Program-array plane buffer, split into the two endpoints. WIDE_PLANE_A_ATTRIBUTES = ( (ATTR_POSITION, 3, 0), ) WIDE_PLANE_B_ATTRIBUTES = ( (ATTR_POSITION_B, 3, 0), ) #: Program-array attribute buffer. Bound at the segment's **end** vertex only: #: that is how the expanded path reproduces the last-vertex convention the #: strip gets from ``flat`` interpolation, and so keeps the KIND_NOOP contract #: - a jump kills the segment *into* it and not the one out of it. WIDE_ATTR_B_ATTRIBUTES = ( (ATTR_KINDTOOL, 1, 4, GL_UNSIGNED_INT), ) # rs274.glcanon_bake names the primitive a part wants rather than importing GL, # so it stays GL-free and unit-testable. This is the only place the names are # turned into enums. PRIMITIVE_MODES = { "line_strip": GL_LINE_STRIP, "lines": GL_LINES, } def primitive_mode(name: str | None, default: GLEnum = GL_LINE_STRIP) -> GLEnum: """The GL enum for a baked part's named primitive mode.""" if name is None: return default try: return PRIMITIVE_MODES[name] except KeyError: raise ValueError("unknown primitive mode %r" % (name,)) # Palette slots. Four cover the program's categories; the live backplot needs # six (one per motion type the position logger distinguishes), so the array is # eight - the next size that costs nothing to reason about and leaves room. A # vec4[8] uniform array is 128 bytes. ``PaletteRGBA``, imported above, is the # array as a type. PALETTE_SIZE = 8 _GL_ERROR_NAMES = { GL_INVALID_ENUM: "GL_INVALID_ENUM", GL_INVALID_VALUE: "GL_INVALID_VALUE", GL_INVALID_OPERATION: "GL_INVALID_OPERATION", GL_INVALID_FRAMEBUFFER_OPERATION: "GL_INVALID_FRAMEBUFFER_OPERATION", GL_OUT_OF_MEMORY: "GL_OUT_OF_MEMORY", } def gl_error_name(err: GLEnum) -> str: return _GL_ERROR_NAMES.get(err, "0x%04x" % err) def check_gl_error(where: str) -> None: """Drain glGetError and raise if anything is pending (debug builds only). Call after a logical pass (clear, program draw, FBO read) with a short label so a driver error is attributed to the pass that caused it rather than the next unrelated GL call. """ if not GL_DEBUG: return errors = [] while True: err = glGetError() if err == GL_NO_ERROR: break errors.append(gl_error_name(err)) if errors: raise RuntimeError("GL error(s) at %s: %s" % (where, ", ".join(errors))) # --------------------------------------------------------------------------- # Capability record # # The renderer is one code path across two APIs. Where they differ it branches # on this record, read once from the live context, rather than on an extension # query at the point of use or on an exception the driver happened to raise. # --------------------------------------------------------------------------- #: The prefix every OpenGL ES implementation must put in front of GL_VERSION #: ("OpenGL ES 3.1 Mesa 23.2.1"). Desktop GL_VERSION begins with the number. #: Specified on both APIs, so it needs no extension query - and unlike "did we #: come through EGL?", it is not confused by a desktop context on EGL, which is #: what the headless test harness and Wayland sessions both use. GLES_VERSION_PREFIX = "OpenGL ES " @dataclass(frozen=True) class GLCaps: """What the live context can do, where the two APIs differ. Frozen because it describes a context, not a preference: a part that could write to it would be choosing its own capabilities. Constructed once (see :func:`active_caps`) and passed down; no scene part queries GL itself. The defaults describe desktop GL 3.3 core, which is what a caps-free caller - a unit test with no context - should get. """ #: The context is OpenGL ES rather than desktop OpenGL. Read at exactly #: one place - the GLSL preamble :func:`_glsl` prepends at compile time. is_gles: bool = False #: GL_ALIASED_LINE_WIDTH_RANGE's maximum. Core profiles guarantee only #: 1.0, and Mesa's v3d grants exactly that; a part wanting more than this #: gets it by quad expansion rather than by asking twice. Not an API #: question - a forward-compatible desktop core profile answers 1.0 too. max_line_width: float = 1.0 @classmethod def from_version(cls, version: str, max_line_width: float = 1.0) -> GLCaps: """Build from the strings a context reports. No GL calls, so this is the form the unit tests exercise.""" return cls(is_gles=version.startswith(GLES_VERSION_PREFIX), max_line_width=float(max_line_width)) @classmethod def probe(cls) -> GLCaps: """Read the capabilities of the context that is current now. Both queries are core in GL 3.0+ and GLES 3.0+ alike. A driver that refuses one leaves the conservative default in place rather than failing the frame. """ version = _gl_string(GL_VERSION) widths = _gl_floats(GL_ALIASED_LINE_WIDTH_RANGE, 2, (1.0, 1.0)) return cls.from_version(version, max_line_width=widths[1]) def describe(self) -> str: """One line for the startup log: which API and version actually ran. On a Raspberry Pi the two paths look identical from the outside, so a bug report has to be able to say which one it was without asking the reporter to run eglinfo. """ return "%s (renderer: %s, max line width %.1f)" % ( _gl_string(GL_VERSION) or "unknown GL version", _gl_string(GL_RENDERER) or "unknown", self.max_line_width) def _gl_string(name: GLEnum) -> str: try: value = glGetString(name) except Exception: _drain_gl_error() return "" if value is None: return "" if isinstance(value, bytes): return value.decode("ascii", "replace") return str(value) def _gl_floats(name: GLEnum, count: int, default: Sequence[float]) -> tuple[float, ...]: try: values = glGetFloatv(name) except Exception: _drain_gl_error() return tuple(default) try: flat = [float(v) for v in np.asarray(values).reshape(-1)[:count]] except Exception: return tuple(default) return tuple(flat) if len(flat) == count else tuple(default) #: The capabilities of the process's preview context, probed on first use. #: #: A module global for the same reason the line-width probe below is one: the #: shader compiler needs the GLSL version before any renderer object is in #: scope (the glyph atlas is built by glnav, which has no renderer), and every #: preview context in one process comes from the same driver and the same #: request. :meth:`GlCanonRenderer.caps` is the supported way to read it; #: nothing outside this module should call it directly. _ACTIVE_CAPS: GLCaps | None = None def active_caps() -> GLCaps: """The process's :class:`GLCaps`, probing the current context once.""" global _ACTIVE_CAPS if _ACTIVE_CAPS is None: _ACTIVE_CAPS = GLCaps.probe() return _ACTIVE_CAPS def reset_active_caps(caps: GLCaps | None = None) -> None: """Forget the probed capabilities, or force a specific record. For context loss and for tests that drive both APIs in one process. Not part of the drawing path. The line-width verdict goes with them, and so does the context it was reached in: leaving the latter behind would let the next :func:`set_line_width` compare a fresh probe against a dead context's token and discard the answer it had just made. """ global _ACTIVE_CAPS, _MAX_LINE_WIDTH, _LINE_WIDTH_CONTEXT _ACTIVE_CAPS = caps _MAX_LINE_WIDTH = None _LINE_WIDTH_CONTEXT = None _MAX_LINE_WIDTH: float | None = None def _drain_gl_error() -> None: """Swallow any pending GL errors so they don't surface on a later call.""" for _ in range(32): try: if glGetError() == GL_NO_ERROR: return except Exception: return def _try_line_width(w: float) -> bool: """glLineWidth(w) that reports success, swallowing driver rejection. A forward-compatible core context (e.g. Qt's) rejects glLineWidth(>1) with GL_INVALID_VALUE - which PyOpenGL turns into an exception and/or leaves as a pending error. Both are absorbed here so the caller degrades gracefully and no stale error propagates to the next GL call. The drains before and after the call are one pair, and neither is redundant. glGetError reports the *oldest* queued error, so without the drain in front, an error queued by something else is read as this call's refusal - and since the verdict is cached (:func:`set_line_width`), one foreign error clamps every width the preview asks for. The drain behind keeps a genuine refusal here from surfacing on someone else's next call. The foreign error need not come from Python: PyOpenGL raises on its own calls and consumes the flag doing so, but ``togl.c`` and whatever ``user_plot()`` resolves to draw into the same context without going through it. Reproduced in the dev container on llvmpipe, a context that accepts glLineWidth(3.0): one GL_INVALID_ENUM issued through libGL by ctypes was enough to pin the granted width at 1.0 for the session. """ _drain_gl_error() try: glLineWidth(w) except Exception: _drain_gl_error() return False err = GL_NO_ERROR try: err = glGetError() except Exception: pass _drain_gl_error() return err == GL_NO_ERROR #: The width the scene last *asked* for, whatever the driver then granted. #: Kept because a buffer that can quad-expand needs the request, not the #: clamp - see :func:`pending_line_expansion`. _REQUESTED_LINE_WIDTH: float = 1.0 #: The context ``_MAX_LINE_WIDTH`` was probed in, or None for "not yet probed". #: The verdict describes a context, not a process - see :func:`_context_token`. _LINE_WIDTH_CONTEXT: int | None = None #: The (library, symbol) pairs :func:`_context_token` asks, in order. Resolved #: lazily on first use and then reused; ``()`` records that neither could be #: opened, so the lookup is not retried on every frame. Pairs rather than two #: lists: with a missing libEGL, a positional match would ask libGL for the EGL #: symbol, fail, and never reach the GLX one. _CONTEXT_PROBES: tuple[tuple[Any, str], ...] | None = None def _context_token() -> int: """An opaque identity for the GL context that is current now, or 0. The width probe's answer belongs to the context it was made in, so the cache needs to notice when a different context becomes current. There is no portable "current context" call, so this asks the two window-system bindings the preview is ever built on - EGL (Qt on most builds, and the test corpus) and GLX (``gremlin.py``, ``togl.c``) - and takes whichever answers. EGL first: where both libraries are present but GLX is what is in use, ``eglGetCurrentContext`` returns EGL_NO_CONTEXT and the GLX call is reached. 0 means the question could not be asked - no library, no symbol, no current context. The caller then behaves as it did before this was here, caching process-wide, which is right for the single-context case that 0 mostly means. """ global _CONTEXT_PROBES if _CONTEXT_PROBES is None: probes = [] for name, symbol in (("EGL", "eglGetCurrentContext"), ("GL", "glXGetCurrentContext")): try: path = ctypes.util.find_library(name) lib = ctypes.CDLL(path) if path else None fn = getattr(lib, symbol) if lib else None except Exception: fn = None if fn is not None: # Without this the default int restype truncates a 64-bit # handle, and two contexts can compare equal on their low # 32 bits. fn.restype = ctypes.c_void_p probes.append((fn, symbol)) _CONTEXT_PROBES = tuple(probes) for fn, _symbol in _CONTEXT_PROBES: try: handle = fn() except Exception: continue if handle: return int(handle) return 0 def set_line_width(width: float) -> None: """Set the line width, degrading thick lines to 1.0 where the driver only supports width 1.0 (core profiles guarantee no more, and GLES on Mesa's v3d grants exactly 1.0). Probed once per context, cached. The requested width is remembered even when it is refused, so a buffer small enough to quad-expand can still honour it. The program trajectory is deliberately not one of those: see :meth:`ProgramArrayBuffers.draw`. **The probe is the source of truth, not** ``GLCaps.max_line_width``. Replacing it with the capability query looks like an obvious tidy-up and is wrong: ``GL_ALIASED_LINE_WIDTH_RANGE`` reports 255 in exactly the forward-compatible core context that rejects 3, because the range does not account for context restrictions. The query would then say "granted" where the driver refuses, and every part that asks for a wide line - the highlight, the dwell markers, the grid - would draw at one pixel with nothing left to notice it. ``GLCaps.max_line_width`` stays for describing a context; it does not decide a width. """ global _MAX_LINE_WIDTH, _REQUESTED_LINE_WIDTH, _LINE_WIDTH_CONTEXT _REQUESTED_LINE_WIDTH = max(1.0, float(width)) token = _context_token() # Only a *change* discards. Starting from None adopts the current context # without touching the verdict, so a test that forces _MAX_LINE_WIDTH to # stand in for a driver keeps the value it set. if _LINE_WIDTH_CONTEXT is not None and token != _LINE_WIDTH_CONTEXT: _MAX_LINE_WIDTH = None _LINE_WIDTH_CONTEXT = token if _MAX_LINE_WIDTH is None: _MAX_LINE_WIDTH = 3.0 if _try_line_width(3.0) else 1.0 _warn_if_range_disagrees(_MAX_LINE_WIDTH) w = max(1.0, min(float(width), _MAX_LINE_WIDTH)) if not _try_line_width(w) and w != 1.0: _MAX_LINE_WIDTH = 1.0 _try_line_width(1.0) #: Contexts a width disagreement has already been reported for, so the note is #: made once per context rather than once per probe. _RANGE_DISAGREEMENT_SEEN: set[int] = set() def _warn_if_range_disagrees(granted: float) -> None: """Record, once per context, a probe that refuses a width the reported range covers. Two different causes produce a thin line - a context that removes wide lines (expected, and fixed by not asking for one), and a probe that read someone else's error (a defect). From a screenshot they are the same picture. This is the line that tells them apart without a debugger. Silent when the two agree, which is every healthy session and, on a Pi's ``v3d``, every honest refusal too: it reports ``[1, 1]``, so there is nothing to disagree about. """ if granted >= 3.0: return reported = _gl_floats(GL_ALIASED_LINE_WIDTH_RANGE, 2, (1.0, 1.0))[1] if reported < 3.0: return token = _context_token() if token in _RANGE_DISAGREEMENT_SEEN: return _RANGE_DISAGREEMENT_SEEN.add(token) log.warning( "preview: driver refused glLineWidth(3.0) though " "GL_ALIASED_LINE_WIDTH_RANGE reports a maximum of %.1f; " "wide lines will be drawn at %.1f. A forward-compatible context " "does this by design; anything else means the width probe read an " "error it did not cause.", reported, granted) def pending_line_expansion() -> float: """The width a quad-expanding buffer should draw at, or 0 for "none". Non-zero exactly when the scene asked for a width the driver would not give - GLES on v3d, and equally a forward-compatible desktop core profile that refuses anything above 1.0. Branching on the granted width rather than on the API is what keeps the expansion path exercised by the desktop test corpus instead of only on a Pi. """ granted = 1.0 if _MAX_LINE_WIDTH is None else _MAX_LINE_WIDTH return _REQUESTED_LINE_WIDTH if _REQUESTED_LINE_WIDTH > granted else 0.0 # --------------------------------------------------------------------------- # Shader / program helpers # --------------------------------------------------------------------------- #: GLSL ES 3.00 is the version paired with GLES 3.0/3.1 and is what the #: intersection compiles to. The two precision statements are not decoration: #: the ES fragment language defines no default precision for float at all, so #: every fragment stage here fails to compile without the first, and defaults #: int to mediump - only 16 bits - which would silently truncate the 32-bit #: source-line ids the pick stage packs into a colour, hence the second. GLSL_ES_PREAMBLE = """#version 300 es precision highp float; precision highp int; """ #: GLSL 330 is the version paired with OpenGL 3.3 core. GLSL_CORE_PREAMBLE = """#version 330 core """ def _glsl(source: str, caps: GLCaps | None = None) -> str: """``source`` with the right ``#version`` line for the live API in front. The shader bodies in this module carry no version directive, so there is one body per stage rather than one per API: the only difference between a GLES compile and a desktop compile is what this function prepends. """ if caps is None: caps = active_caps() return (GLSL_ES_PREAMBLE if caps.is_gles else GLSL_CORE_PREAMBLE) + source def compile_shader(source: str, shader_type: GLEnum, caps: GLCaps | None = None) -> int: """Compile one shader stage; raise RuntimeError with the info log on failure. ``source`` is a bare shader body: the version directive and, on GLES, the precision statements are prepended here. This is the module's only glShaderSource, so no stage can escape the preamble. """ source = _glsl(source, caps) shader = glCreateShader(shader_type) glShaderSource(shader, source) glCompileShader(shader) if glGetShaderiv(shader, GL_COMPILE_STATUS) != GL_TRUE: log = glGetShaderInfoLog(shader) if isinstance(log, bytes): log = log.decode("utf-8", "replace") glDeleteShader(shader) kind = "vertex" if shader_type == GL_VERTEX_SHADER else \ "fragment" if shader_type == GL_FRAGMENT_SHADER else str(shader_type) raise RuntimeError("%s shader compile failed:\n%s" % (kind, log)) return shader def link_program(*shaders: int) -> int: """Link the given compiled shaders into a program; raise on failure. The shaders are detached and deleted after a successful link (the program retains them), so the caller only owns the returned program handle. """ program = glCreateProgram() for shader in shaders: glAttachShader(program, shader) glLinkProgram(program) linked = glGetProgramiv(program, GL_LINK_STATUS) for shader in shaders: glDetachShader(program, shader) glDeleteShader(shader) if linked != GL_TRUE: log = glGetProgramInfoLog(program) if isinstance(log, bytes): log = log.decode("utf-8", "replace") glDeleteProgram(program) raise RuntimeError("program link failed:\n%s" % log) return program class ShaderProgram: """A linked GLSL program with a cached uniform-location lookup.""" def __init__(self, vertex_source: str, fragment_source: str, caps: GLCaps | None = None) -> None: vs = compile_shader(vertex_source, GL_VERTEX_SHADER, caps) fs = compile_shader(fragment_source, GL_FRAGMENT_SHADER, caps) self.program = link_program(vs, fs) self._uniforms: dict[str, int] = {} def use(self) -> None: glUseProgram(self.program) def uniform(self, name: str) -> int: loc = self._uniforms.get(name) if loc is None: loc = glGetUniformLocation(self.program, name) self._uniforms[name] = loc return loc def set_mat4(self, name: str, matrix: Any) -> None: # glnav matrices are row-major (math convention); GL wants column-major, # so upload with transpose=GL_TRUE rather than transposing in numpy. m = np.ascontiguousarray(matrix, dtype=np.float32) glUniformMatrix4fv(self.uniform(name), 1, GL_TRUE, m) def set_float(self, name: str, value: float) -> None: glUniform1f(self.uniform(name), float(value)) def set_int(self, name: str, value: int) -> None: glUniform1i(self.uniform(name), int(value)) def set_bool(self, name: str, value: Any) -> None: glUniform1i(self.uniform(name), 1 if value else 0) def set_vec4(self, name: str, x: float, y: float, z: float, w: float) -> None: glUniform4f(self.uniform(name), x, y, z, w) def delete(self) -> None: if self.program: glDeleteProgram(self.program) self.program = 0 # --------------------------------------------------------------------------- # Buffer / vertex-array wrappers # --------------------------------------------------------------------------- def _as_bytes(array: Any) -> Any: """A contiguous array to hand GL, whatever layout it came in. A plain float array is taken as float32, which is what every interleaved layout here is. A structured array - the program's two-buffer layout is one - is taken as it stands, because its dtype *is* the layout and coercing it to float32 would reinterpret the integer columns as numbers. """ array = np.asarray(array) if array.dtype.fields is not None: return np.ascontiguousarray(array) return np.ascontiguousarray(array, dtype=np.float32) class GLBuffer: """A single VBO. `set_data` (re)allocates; `update_sub` uploads a range.""" def __init__(self, target: GLEnum = GL_ARRAY_BUFFER) -> None: self.target = target #: The GL name glGenBuffers handed out. A plain int, not a GLEnum. self.handle: int = glGenBuffers(1) self.size_bytes = 0 def bind(self) -> None: glBindBuffer(self.target, self.handle) def set_data(self, array: Any, usage: GLEnum = GL_STATIC_DRAW) -> None: data = _as_bytes(array) self.bind() glBufferData(self.target, data.nbytes, data, usage) self.size_bytes = data.nbytes def orphan(self, size_bytes: int, usage: GLEnum = GL_DYNAMIC_DRAW) -> None: """Allocate `size_bytes` of uninitialised storage (ring-buffer backing).""" self.bind() glBufferData(self.target, int(size_bytes), None, usage) self.size_bytes = int(size_bytes) def update_sub(self, byte_offset: int, array: Any) -> None: data = _as_bytes(array) self.bind() glBufferSubData(self.target, int(byte_offset), data.nbytes, data) def delete(self) -> None: if self.handle: glDeleteBuffers(1, [self.handle]) self.handle = 0 class VertexArray: """A VAO. `configure` wires an interleaved VBO to a set of attributes.""" def __init__(self) -> None: self.handle: int = glGenVertexArrays(1) def bind(self) -> None: glBindVertexArray(self.handle) def unbind(self) -> None: glBindVertexArray(0) def configure(self, buffer: GLBuffer, attributes: Sequence[Sequence[int]] = LINE_ATTRIBUTES, stride: int = VERTEX_STRIDE, divisor: int = 0, base: int = 0) -> None: """Bind `buffer` and enable `attributes`. Each attribute is ``(location, size, offset)`` - float components, the common case - or ``(location, size, offset, gl_type)``. An integer type goes through glVertexAttribIPointer so it reaches the shader as an integer rather than being converted to float, which is what lets the trajectory carry a packed uint32 the shader can mask and shift. ``divisor`` and ``base`` are what the quad-expanded line path is made of: the same buffer is bound twice, once at ``base = 0`` and once at ``base = one vertex``, both with ``divisor = 1`` and a stride of one *segment*, so each instance sees a segment's two endpoints without a byte of vertex data being duplicated. Instanced arrays are core in both GL 3.3 and GLES 3.0. """ self.bind() buffer.bind() for attribute in attributes: location, size, offset = attribute[:3] gl_type = attribute[3] if len(attribute) > 3 else GL_FLOAT glEnableVertexAttribArray(location) if gl_type in _INTEGER_ATTRIB_TYPES: glVertexAttribIPointer(location, size, gl_type, stride, ctypes_offset(base + offset)) else: glVertexAttribPointer(location, size, gl_type, GL_FALSE, stride, ctypes_offset(base + offset)) if divisor: glVertexAttribDivisor(location, divisor) self.unbind() def delete(self) -> None: if self.handle: glDeleteVertexArrays(1, [self.handle]) self.handle = 0 def ctypes_offset(byte_offset: int) -> Any: """A void* offset for glVertexAttribPointer (None means 0).""" import ctypes return ctypes.c_void_p(int(byte_offset)) # --------------------------------------------------------------------------- # Line shader (GL 3.3 core / GLES 3.1 intersection) # --------------------------------------------------------------------------- LINE_VERTEX_SHADER = """ layout(location = 0) in vec3 in_position; layout(location = 1) in vec4 in_color; layout(location = 2) in float in_lineno; uniform mat4 u_mvp; out vec4 v_color; void main() { gl_Position = u_mvp * vec4(in_position, 1.0); v_color = in_color; } """ LINE_FRAGMENT_SHADER = """ in vec4 v_color; uniform float u_alpha; // multiplies vertex alpha (program_alpha) uniform bool u_use_override; // draw a single flat colour (e.g. highlight) uniform vec4 u_override_color; out vec4 frag_color; void main() { vec4 c = u_use_override ? u_override_color : v_color; frag_color = vec4(c.rgb, c.a * u_alpha); } """ # --------------------------------------------------------------------------- # Wide lines, without glLineWidth. # # A core profile guarantees only width 1.0, and Mesa's v3d - the Raspberry Pi's # driver - grants exactly that, so on a Pi every glLineWidth above 1 is refused # and the whole preview would be hairlines. The portable answer is to expand # each segment into a screen-space quad in the vertex shader. # # That is applied ONLY to buffers whose vertex counts are trivial and whose # width carries meaning: the transient grid/axes/extents/label arrays, the # dwell markers, and the highlight overlay. The program trajectory keeps # GL_LINE_STRIP at whatever the driver grants - expanding 10M vertices costs # about 4x the vertex shading and 2x the fragments, which on a tile-based GPU # is the difference between a slow preview and an unusable one. See # `openspec/.../gles-compatible-renderer/design.md` decision 5. # # The expansion runs when the driver refused the width that was asked for, not # when the API is GLES: a forward-compatible desktop core profile (Qt's) also # refuses widths above 1, so the desktop corpus exercises this path rather than # leaving it to be discovered on hardware. # --------------------------------------------------------------------------- WIDE_LINE_EXPAND = """ uniform vec2 u_viewport; // drawable size in pixels uniform float u_line_width; // desired width in pixels // Where corner ``gl_VertexID`` of the quad expanding the segment a->b belongs, // in clip space. Corners 0/1 sit at a and 2/3 at b; odd corners take the // +normal side. Drawn as a 4-vertex triangle strip, one instance per segment, // so a segment's two endpoints arrive as two bindings of one buffer. vec4 expand(vec4 ca, vec4 cb) { vec4 clip = (gl_VertexID > 1) ? cb : ca; float side = ((gl_VertexID & 1) == 1) ? 1.0 : -1.0; vec2 half_vp = 0.5 * u_viewport; vec2 offset = vec2(0.0); // A non-positive w means the segment crosses the eye plane and has no // honest screen-space direction; it collapses to the unexpanded point // rather than flaring across the viewport. Butt caps, as GL wide lines // have, so no extension along the segment either. if (ca.w > 0.0 && cb.w > 0.0 && half_vp.x > 0.0 && half_vp.y > 0.0) { vec2 d = (cb.xy / cb.w - ca.xy / ca.w) * half_vp; float len = length(d); if (len > 0.0) offset = vec2(-d.y, d.x) / len * (0.5 * u_line_width * side); } return vec4(clip.xy + offset / half_vp * clip.w, clip.z, clip.w); } """ #: The line shader's vertex stage, quad-expanding. Same outputs as #: ``LINE_VERTEX_SHADER``, so the fragment stage is shared unchanged. WIDE_LINE_VERTEX_SHADER = """ layout(location = 0) in vec3 in_position; layout(location = 1) in vec4 in_color; layout(location = 5) in vec3 in_position_b; layout(location = 6) in vec4 in_color_b; uniform mat4 u_mvp; out vec4 v_color; %(expand)s void main() { vec4 ca = u_mvp * vec4(in_position, 1.0); vec4 cb = u_mvp * vec4(in_position_b, 1.0); v_color = (gl_VertexID > 1) ? in_color_b : in_color; gl_Position = expand(ca, cb); } """ % {"expand": WIDE_LINE_EXPAND} class LineProgram: """The line shader plus its default uniform state. A draw call is: :meth:`use`, set ``u_mvp`` (and any alpha/override uniforms), bind a configured VAO, then ``glDrawArrays``. :meth:`begin` resets the optional uniforms to their inert defaults so each pass starts from a known state. ``VERTEX_SHADER`` names which vertex stage a subclass compiles - the plain one, or :class:`WideLineProgram`'s quad-expanding one. The fragment stage and every uniform below are shared. """ VERTEX_SHADER = LINE_VERTEX_SHADER def __init__(self) -> None: self.shader = ShaderProgram(self.VERTEX_SHADER, LINE_FRAGMENT_SHADER) def use(self) -> None: self.shader.use() def begin(self, mvp: Any, alpha: float = 1.0) -> None: """Start a pass: bind program, load the MVP, clear optional state.""" self.shader.use() self.shader.set_mat4("u_mvp", mvp) self.shader.set_float("u_alpha", alpha) self.shader.set_bool("u_use_override", False) def set_alpha(self, alpha: float) -> None: self.shader.set_float("u_alpha", alpha) def set_override_color(self, rgba: Sequence[float] | None) -> None: if rgba is None: self.shader.set_bool("u_use_override", False) else: r, g, b, a = rgba self.shader.set_bool("u_use_override", True) self.shader.set_vec4("u_override_color", r, g, b, a) def delete(self) -> None: self.shader.delete() class WideLineProgram(LineProgram): """:class:`LineProgram` with the quad-expanding vertex stage.""" VERTEX_SHADER = WIDE_LINE_VERTEX_SHADER def set_expansion(self, viewport: Sequence[float], width: float) -> None: """The two uniforms the expansion needs: the drawable size in pixels and the width to draw at. Set per pass, after :meth:`begin`.""" glUniform2f(self.shader.uniform("u_viewport"), float(viewport[0]), float(viewport[1])) self.shader.set_float("u_line_width", width) # --------------------------------------------------------------------------- # Trajectory shader: the program's own line shader. # # The line shader above stays as it is - the dwell markers, the live backplot # and the transient grid/axes/label geometry each carry a colour per vertex and # cannot be reduced to a four-entry palette. The program can, and that is what # pays for the 16-byte vertex, so it gets its own pair of stages. # # The category is carried `flat`: adjacent segments of one chain routinely # differ in category, and an interpolated code would blend the rapid's colour # into the feed across the join. Flat also means each segment takes its # provoking (last) vertex's line number, which is the existing picking and # highlight contract - a segment belongs to the source line of its END point. # --------------------------------------------------------------------------- TRAJ_VERTEX_SHADER = """ layout(location = 0) in vec3 in_position; layout(location = 2) in uint in_packed; // lineno | category << 24 uniform mat4 u_mvp; flat out uint v_kind; void main() { gl_Position = u_mvp * vec4(in_position, 1.0); v_kind = in_packed >> 24u; } """ # The program array's vertex stage. Same outputs as the packed one above, off # the two-buffer 20-byte layout: the line number is its own uint32 attribute # and the kind rides in the low byte of the kind/tool word. PROGRAM_VERTEX_SHADER = """ layout(location = 0) in vec3 in_position; layout(location = 4) in uint in_kindtool; uniform mat4 u_mvp; flat out uint v_kind; void main() { gl_Position = u_mvp * vec4(in_position, 1.0); v_kind = in_kindtool & %(kind_mask)uu; } """ % {"kind_mask": KIND_MASK} TRAJ_FRAGMENT_SHADER = """ flat in uint v_kind; uniform vec4 u_palette[%(palette)d]; uniform float u_alpha; // multiplies the palette alpha (program_alpha) uniform int u_hide_cat; // kind that is discarded, -1 for none uniform int u_last_drawn_kind; // kinds above this are records, never drawn uniform bool u_use_override; // draw a single flat colour (the highlight) uniform vec4 u_override_color; out vec4 frag_color; void main() { int cat = int(v_kind); // Structural, and first: kinds above the last drawn one are records - a // coordinate jump, a dwell, a tool change - and are not geometry at all. // One comparison rather than an enumeration, which is what the ordering // of the kind codes is for. Outside the override test on purpose: the // highlight pass overrides the rapid-hiding toggle, and must not be able // to resurrect a record by doing so. if (cat > u_last_drawn_kind) discard; // Which kind is hidden is the drawing buffer's to nominate, not a property // of the number zero: the program nominates its rapid code, the dwell // markers and the live backplot nominate none and so are never discarded // whatever code their vertices carry. The highlight pass overrides the // nomination - it draws the selected line whether or not rapids are shown, // exactly as it did when rapids were a separate buffer whose draw call was // skipped. if (!u_use_override && cat == u_hide_cat) discard; vec4 c = u_use_override ? u_override_color : u_palette[cat]; frag_color = vec4(c.rgb, c.a * u_alpha); } """ % {"palette": PALETTE_SIZE} def _pin_provoking_vertex() -> None: """Ask for the last-vertex provoking convention, explicitly. GL's default already is last-vertex, and that default is what makes a segment take its END vertex's flat line number and category - the existing picking and highlight contract. Saying so costs one call at program creation and removes the dependence on nothing else in the process having changed it. Swallowed if the context does not expose it (the convention is then the default anyway). """ try: glProvokingVertex(GL_LAST_VERTEX_CONVENTION) except Exception: pass _drain_gl_error() class TrajectoryProgram: """The trajectory shader plus its palette and pass state. Two vertex stages share one fragment stage: the packed 16-byte layout the live backplot uses, and the program array's 20-byte one. They differ only in where the kind comes from, so the colouring and the record-kind rejection are written once. ``VERTEX_SHADER`` names which one a subclass compiles. """ VERTEX_SHADER = TRAJ_VERTEX_SHADER def __init__(self) -> None: self.shader = ShaderProgram(self.VERTEX_SHADER, TRAJ_FRAGMENT_SHADER) _pin_provoking_vertex() def use(self) -> None: self.shader.use() def begin(self, mvp: Any, palette: Any, alpha: float = 1.0, hide_cat: int = -1, last_drawn_kind: int = PALETTE_SIZE - 1) -> None: """Start a pass. ``hide_cat`` is the drawing buffer's own kind code, or -1 for "this buffer hides nothing". ``last_drawn_kind`` is likewise the buffer's own: the program array carries record kinds above it, while a buffer that has none - the markers, the backplot - says so by naming its whole palette. """ self.shader.use() self.shader.set_mat4("u_mvp", mvp) self.set_palette(palette) self.shader.set_float("u_alpha", alpha) self.shader.set_int("u_hide_cat", hide_cat) self.shader.set_int("u_last_drawn_kind", last_drawn_kind) self.shader.set_bool("u_use_override", False) def set_palette(self, palette: Any) -> None: """Upload a palette, padding short ones to the uniform array's size. A buffer supplies only the entries it uses - three for the program, six for the backplot - and the rest are never indexed, but the uniform array is uploaded whole, so the tail has to be something rather than whatever was left in the caller's array. """ entries: PaletteRGBA = np.zeros((PALETTE_SIZE, 4), dtype=np.float32) given = np.ascontiguousarray(palette, dtype=np.float32).reshape(-1, 4) n = min(len(given), PALETTE_SIZE) entries[:n] = given[:n] glUniform4fv(self.shader.uniform("u_palette"), PALETTE_SIZE, entries) def set_override_color(self, rgba: Sequence[float] | None) -> None: if rgba is None: self.shader.set_bool("u_use_override", False) else: r, g, b, a = rgba self.shader.set_bool("u_use_override", True) self.shader.set_vec4("u_override_color", r, g, b, a) def delete(self) -> None: self.shader.delete() TRAJ_PICK_VERTEX_SHADER = """ layout(location = 0) in vec3 in_position; layout(location = 2) in uint in_packed; uniform mat4 u_mvp; flat out uint v_kind; flat out uint v_id; void main() { gl_Position = u_mvp * vec4(in_position, 1.0); v_kind = in_packed >> 24u; v_id = (in_packed & 0xFFFFFFu) + 1u; // +1: id 0 == "no hit" } """ # The program array's pick vertex stage: a full 32-bit line number, its own # attribute, and the kind out of the kind/tool word. PROGRAM_PICK_VERTEX_SHADER = """ layout(location = 0) in vec3 in_position; layout(location = 2) in uint in_lineno; layout(location = 4) in uint in_kindtool; uniform mat4 u_mvp; flat out uint v_kind; flat out uint v_id; void main() { gl_Position = u_mvp * vec4(in_position, 1.0); v_kind = in_kindtool & %(kind_mask)uu; v_id = in_lineno + 1u; // +1: id 0 == "no hit" } """ % {"kind_mask": KIND_MASK} # Window-space depth, packed into an RGBA8 colour attachment. # # OpenGL ES permits glReadPixels on colour attachments only - there is no # extension that lifts it, so on a Pi the depth *buffer* cannot be read back at # all. The pick pass therefore writes depth a second time, as colour, into a # second attachment. It does so on **both** APIs: a desktop-only # GL_DEPTH_COMPONENT branch would leave this code untested by the desktop pick # corpus, which is the whole value of sharing it. # # 24 bits of fixed point - exactly the precision of the GL_DEPTH_COMPONENT24 # renderbuffer it mirrors, so nothing is lost against the value the depth test # itself used, and the nearest-hit ordering is preserved bit for bit. Integer # packing rather than the usual vec3/fract trick because the ES preamble # declares `precision highp int`, which makes the arithmetic exact on both. #: The largest value the packer can produce, and the sentinel decode compares #: against. 24 bits, matching GL_DEPTH_COMPONENT24. DEPTH_MAX = (1 << 24) - 1 def pack_depth_rgba8(values: Any) -> bytes: """The bytes ``DEPTH_TO_RGBA8`` would write for depths in 0..1. The GLSL packer's counterpart in Python, so a test can build the patch :meth:`PickTarget.decode` reads without needing a GL context to render one. """ z = np.clip(np.asarray(values, dtype=np.float64), 0.0, 1.0) q = np.rint(z * DEPTH_MAX).astype(np.uint32) out = np.zeros(z.shape + (4,), dtype=np.uint8) out[..., 0] = (q & 0xFF).astype(np.uint8) out[..., 1] = ((q >> 8) & 0xFF).astype(np.uint8) out[..., 2] = ((q >> 16) & 0xFF).astype(np.uint8) out[..., 3] = 255 return out.tobytes() DEPTH_TO_RGBA8 = """ const float DEPTH_SCALE = 16777215.0; // 2^24 - 1 vec4 pack_depth(float z) { uint q = uint(clamp(z, 0.0, 1.0) * DEPTH_SCALE + 0.5); return vec4(float( q & 0xFFu) / 255.0, float((q >> 8u) & 0xFFu) / 255.0, float((q >> 16u) & 0xFFu) / 255.0, 1.0); } """ TRAJ_PICK_FRAGMENT_SHADER = """ flat in uint v_kind; flat in uint v_id; uniform int u_hide_cat; // kind that is discarded, -1 for none uniform int u_last_drawn_kind; // kinds above this are records layout(location = 0) out vec4 frag_color; layout(location = 1) out vec4 frag_depth; %(pack)s void main() { // The same two rejections the drawing shader applies, driven by the same // per-buffer uniforms, so pickable geometry cannot diverge from drawn // geometry. int cat = int(v_kind); if (cat > u_last_drawn_kind) discard; if (cat == u_hide_cat) discard; // All four channels. Alpha is free: the target clears to (0,0,0,0), // blending is off for the pass and the read-back already asks for RGBA - // so the id carries every bit of a line number the vertex can hold, and // the cleared pixel still decodes as "no hit". frag_color = vec4( float( v_id & 0xFFu) / 255.0, float((v_id >> 8u) & 0xFFu) / 255.0, float((v_id >> 16u) & 0xFFu) / 255.0, float((v_id >> 24u) & 0xFFu) / 255.0); frag_depth = pack_depth(gl_FragCoord.z); } """ % {"pack": DEPTH_TO_RGBA8} class TrajectoryPickProgram: """The trajectory's ID-buffer pick stage; rejects records and the buffer's hidden kind exactly as the drawing pass does. Paired with :class:`TrajectoryProgram` - same two vertex stages, one fragment stage - for the same reason. """ VERTEX_SHADER = TRAJ_PICK_VERTEX_SHADER def __init__(self) -> None: self.shader = ShaderProgram(self.VERTEX_SHADER, TRAJ_PICK_FRAGMENT_SHADER) def begin(self, mvp: Any, hide_cat: int = -1, last_drawn_kind: int = PALETTE_SIZE - 1) -> None: self.shader.use() self.shader.set_mat4("u_mvp", mvp) self.shader.set_int("u_hide_cat", hide_cat) self.shader.set_int("u_last_drawn_kind", last_drawn_kind) def delete(self) -> None: self.shader.delete() # The program array's quad-expanding vertex stage: the same outputs as # PROGRAM_VERTEX_SHADER off the same two buffers, with the plane buffer bound # twice for the two endpoints and the attribute buffer bound once, at the # segment's END vertex. That single choice is what reproduces the last-vertex # provoking convention here - see WIDE_ATTR_B_ATTRIBUTES. # # Used for the dwell markers and the highlight overlay, never for the program # body; see the WIDE_LINE_EXPAND note. PROGRAM_WIDE_VERTEX_SHADER = """ layout(location = 0) in vec3 in_position; layout(location = 4) in uint in_kindtool; // the segment's END vertex layout(location = 5) in vec3 in_position_b; uniform mat4 u_mvp; flat out uint v_kind; %(expand)s void main() { vec4 ca = u_mvp * vec4(in_position, 1.0); vec4 cb = u_mvp * vec4(in_position_b, 1.0); v_kind = in_kindtool & %(kind_mask)uu; gl_Position = expand(ca, cb); } """ % {"expand": WIDE_LINE_EXPAND, "kind_mask": KIND_MASK} class ProgramArrayProgram(TrajectoryProgram): """:class:`TrajectoryProgram` over the program array's 20-byte layout.""" VERTEX_SHADER = PROGRAM_VERTEX_SHADER class WideProgramArrayProgram(ProgramArrayProgram): """:class:`ProgramArrayProgram` with the quad-expanding vertex stage.""" VERTEX_SHADER = PROGRAM_WIDE_VERTEX_SHADER def set_expansion(self, viewport: Sequence[float], width: float) -> None: """The drawable size in pixels and the width to draw at; see :meth:`WideLineProgram.set_expansion`.""" glUniform2f(self.shader.uniform("u_viewport"), float(viewport[0]), float(viewport[1])) self.shader.set_float("u_line_width", width) class ProgramArrayPickProgram(TrajectoryPickProgram): """:class:`TrajectoryPickProgram` over the program array's layout.""" VERTEX_SHADER = PROGRAM_PICK_VERTEX_SHADER # --------------------------------------------------------------------------- # Pick shader for the per-vertex-colour layout: draw pickable # geometry with the source line number encoded into the colour attachment, for # the offscreen ID-buffer pass that replaces legacy GL_SELECT. The id is # `lineno + 1` so a cleared (black) framebuffer reads back as "no hit". 24 bits # (RGB8) cover ~16M source lines. # # Everything that persists now picks through TRAJ_PICK_* instead. This pair is # reached only when a part's colours would not fit the palette and the bake # fell back to the 32-byte vertex - which cannot happen with the two colours # the canon gives dwells, but is exactly why the fallback has to keep working # rather than be deleted along with its last routine caller. # --------------------------------------------------------------------------- PICK_VERTEX_SHADER = """ layout(location = 0) in vec3 in_position; layout(location = 2) in float in_lineno; uniform mat4 u_mvp; flat out int v_id; void main() { gl_Position = u_mvp * vec4(in_position, 1.0); v_id = int(in_lineno + 0.5) + 1; // +1: id 0 reserved for "no hit" } """ PICK_FRAGMENT_SHADER = """ flat in int v_id; layout(location = 0) out vec4 frag_color; layout(location = 1) out vec4 frag_depth; %(pack)s void main() { // Four channels, matching the trajectory pick stage so one decode serves // both. This stage's id comes from a float attribute and so is 24-bit // anyway; the top byte it writes is zero, which is what the decode reads. frag_color = vec4( float( v_id & 0xFF) / 255.0, float((v_id >> 8) & 0xFF) / 255.0, float((v_id >> 16) & 0xFF) / 255.0, float((v_id >> 24) & 0xFF) / 255.0); // Second attachment, same as the trajectory stage: both pick stages must // write both targets, or a patch mixing them would read stale depth. frag_depth = pack_depth(gl_FragCoord.z); } """ % {"pack": DEPTH_TO_RGBA8} class PickProgram: """The per-vertex-colour pick shader: an MVP and a float line number. Kept for the palette-overflow fallback only; see the note above. """ def __init__(self) -> None: self.shader = ShaderProgram(PICK_VERTEX_SHADER, PICK_FRAGMENT_SHADER) def use(self) -> None: self.shader.use() def set_mvp(self, mvp: Any) -> None: self.shader.use() self.shader.set_mat4("u_mvp", mvp) def delete(self) -> None: self.shader.delete() # position(3) + normal(3) mesh layout for the Lambert-shaded tool cone. MESH_STRIDE = 6 * 4 MESH_ATTRIBUTES = ( (ATTR_POSITION, 3, 0), (ATTR_COLOR, 3, 3 * 4), # reuse location 1 as the normal input ) CONE_VERTEX_SHADER = """ layout(location = 0) in vec3 in_position; layout(location = 1) in vec3 in_normal; uniform mat4 u_mvp; uniform mat3 u_normal_matrix; out vec3 v_normal; void main() { gl_Position = u_mvp * vec4(in_position, 1.0); v_normal = normalize(u_normal_matrix * in_normal); } """ CONE_FRAGMENT_SHADER = """ in vec3 v_normal; uniform vec3 u_light_dir; // direction toward the light (normalised) uniform vec3 u_ambient; uniform vec3 u_diffuse; uniform vec4 u_color; // material colour + alpha out vec4 frag_color; void main() { float ndl = max(dot(normalize(v_normal), normalize(u_light_dir)), 0.0); vec3 c = u_color.rgb * (u_ambient + u_diffuse * ndl); frag_color = vec4(c, u_color.a); } """ class ConeProgram: """Lambert-shaded program for the tool cone / lathe tool solid.""" def __init__(self) -> None: self.shader = ShaderProgram(CONE_VERTEX_SHADER, CONE_FRAGMENT_SHADER) def begin(self, mvp: Any, normal_matrix: Any, color: Sequence[float], light_dir: Sequence[float] = (1.0, -1.0, 1.0), ambient: Sequence[float] = (0.40, 0.40, 0.40), diffuse: Sequence[float] = (0.60, 0.60, 0.60)) -> None: self.shader.use() self.shader.set_mat4("u_mvp", mvp) m = np.ascontiguousarray(normal_matrix, dtype=np.float32) glUniformMatrix3fv(self.shader.uniform("u_normal_matrix"), 1, GL_TRUE, m) ld = np.asarray(light_dir, dtype=np.float64) ld = ld / (np.linalg.norm(ld) or 1.0) glUniform3f(self.shader.uniform("u_light_dir"), *ld) glUniform3f(self.shader.uniform("u_ambient"), *ambient) glUniform3f(self.shader.uniform("u_diffuse"), *diffuse) r, g, b, a = color self.shader.set_vec4("u_color", r, g, b, a) def delete(self) -> None: self.shader.delete() class MeshBuffers: """A position+normal triangle mesh (VBO+VAO) drawn as GL_TRIANGLES.""" def __init__(self) -> None: self.buffer = GLBuffer() self.vao = VertexArray() self.count = 0 self._configured = False def upload(self, verts: MeshVerts) -> None: verts = np.ascontiguousarray(verts, dtype=np.float32) self.count = 0 if verts.size == 0 else verts.shape[0] if self.count: self.buffer.set_data(verts) if not self._configured: self.vao.configure(self.buffer, MESH_ATTRIBUTES, MESH_STRIDE) self._configured = True def draw(self) -> None: if not self.count: return self.vao.bind() glDrawArrays(GL_TRIANGLES, 0, self.count) self.vao.unbind() def delete(self) -> None: self.vao.delete() self.buffer.delete() class PickTarget: """Offscreen framebuffer used by the ID-buffer pick pass. Three attachments: an RGBA8 colour buffer carrying the per-vertex source line number, a second RGBA8 colour buffer carrying window-space depth packed to 24 bits, and a real depth renderbuffer that the depth *test* uses (the pass still needs one - the packed copy is read back, not tested against). The second colour attachment exists because OpenGL ES refuses ``glReadPixels(..., GL_DEPTH_COMPONENT, ...)`` under all circumstances, and it is used on desktop GL too so that one code path serves both and the desktop pick corpus exercises what a Pi runs. Multiple render targets are core in GL 3.3 and GLES 3.0 alike, with at least 4 draw buffers guaranteed; this uses 2. Sized to the preview window; :meth:`ensure` reallocates on a size change. Renderbuffers (not textures) back every attachment because the pass only reads a small patch back with ``glReadPixels``, never samples them. """ def __init__(self) -> None: self.fbo = 0 self.color = 0 #: RGBA8 renderbuffer holding depth as colour - see the class docstring. self.depth_color = 0 self.depth = 0 self.w = 0 self.h = 0 def ensure(self, w: int, h: int) -> None: if self.fbo and (w, h) == (self.w, self.h): return self.delete() self.w, self.h = w, h self.fbo = glGenFramebuffers(1) self.color = glGenRenderbuffers(1) self.depth_color = glGenRenderbuffers(1) self.depth = glGenRenderbuffers(1) glBindRenderbuffer(GL_RENDERBUFFER, self.color) glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, w, h) glBindRenderbuffer(GL_RENDERBUFFER, self.depth_color) glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, w, h) glBindRenderbuffer(GL_RENDERBUFFER, self.depth) glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, w, h) glBindRenderbuffer(GL_RENDERBUFFER, 0) glBindFramebuffer(GL_FRAMEBUFFER, self.fbo) glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, self.color) glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_RENDERBUFFER, self.depth_color) glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, self.depth) status = glCheckFramebufferStatus(GL_FRAMEBUFFER) glBindFramebuffer(GL_FRAMEBUFFER, 0) if status != GL_FRAMEBUFFER_COMPLETE: self.delete() raise RuntimeError( "pick framebuffer incomplete: 0x%x" % int(status)) @contextmanager def offscreen(self) -> Iterator[PickTarget]: """Render into this target, leaving the visible frame undisturbed. Binds the framebuffer, sizes the viewport to it, establishes the state an id pass needs - no blend, so ids stay exact rather than being mixed by coverage - and clears to id 0, meaning "no hit". The previous framebuffer binding and viewport are restored on the way out, including when an exception unwinds through the pass. Read the result back *inside* the block: ``glReadPixels`` takes the currently bound framebuffer, so a resolve after the restore would read the visible frame. """ prev_fbo = int(glGetIntegerv(GL_FRAMEBUFFER_BINDING)) prev_viewport = [int(v) for v in glGetIntegerv(GL_VIEWPORT)] glBindFramebuffer(GL_FRAMEBUFFER, self.fbo) glViewport(0, 0, self.w, self.h) # Both colour attachments are written by the pick stages. Draw-buffer # state belongs to the framebuffer object, so this is set on ours and # needs no restoring on the way out. glDrawBuffers(2, [GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1]) glDisable(GL_BLEND) # solid ids, no coverage blend glEnable(GL_DEPTH_TEST) glDepthFunc(GL_LESS) glClearColor(0.0, 0.0, 0.0, 0.0) # id 0 == no hit glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) try: yield self finally: glBindFramebuffer(GL_FRAMEBUFFER, prev_fbo) glViewport(*prev_viewport) def resolve(self, x: int, y: int) -> int | None: """The line number under window pixel (``x``, ``y``), or None. Reads back the 5x5 patch around the cursor - matching the region the legacy ``gluPickMatrix`` covered - and returns the nearest hit by depth, so a click near two lines takes the one in front. Must be called while this target is bound, i.e. inside :meth:`offscreen`. """ # glReadPixels origin is bottom-left; the window y is top-down. px = int(round(x)); py = self.h - 1 - int(round(y)) rx = max(0, min(px - 2, self.w - 5)) ry = max(0, min(py - 2, self.h - 5)) glPixelStorei(GL_PACK_ALIGNMENT, 1) # Two RGBA8 reads rather than one RGBA8 and one GL_DEPTH_COMPONENT: # ES rejects the latter outright, and using the colour form on both # APIs is what keeps this path covered by the desktop pick corpus. glReadBuffer(GL_COLOR_ATTACHMENT0) color = glReadPixels(rx, ry, 5, 5, GL_RGBA, GL_UNSIGNED_BYTE) glReadBuffer(GL_COLOR_ATTACHMENT1) depth = glReadPixels(rx, ry, 5, 5, GL_RGBA, GL_UNSIGNED_BYTE) glReadBuffer(GL_COLOR_ATTACHMENT0) return self.decode(color, depth) @staticmethod def decode(color: Any, depth: Any) -> int | None: """Decode a 5x5 id/depth patch to the nearest-hit line number, or None. Both patches are RGBA8: ``color`` carries the source line id in all four channels, ``depth`` carries window-space depth as a 24-bit little-endian fixed point in RGB (see ``DEPTH_TO_RGBA8``). The depth values are compared, never scaled back to 0..1, so the fixed point is the comparison and no float conversion can reorder it. Kept apart from the read-back because it is pure: the nearest-by-depth rule and the empty-space case are checkable without a GL context. ``bytes()`` normalises PyOpenGL's return (numpy array or raw buffer) into a byte string this can reinterpret regardless of the build's numpy integration. """ rgba = np.frombuffer(bytes(color), dtype=np.uint8).reshape(5, 5, 4) dz = np.frombuffer(bytes(depth), dtype=np.uint8).reshape(5, 5, 4) # All four channels: alpha carries the id's top byte, so a line number # that fits the vertex also fits a pick. The target clears to # (0,0,0,0) and blending is off for the pass, so alpha is the id's and # nothing else's - and the cleared pixel is still id 0, "no hit". ids = (rgba[..., 0].astype(np.uint32) | (rgba[..., 1].astype(np.uint32) << 8) | (rgba[..., 2].astype(np.uint32) << 16) | (rgba[..., 3].astype(np.uint32) << 24)) d = (dz[..., 0].astype(np.uint32) | (dz[..., 1].astype(np.uint32) << 8) | (dz[..., 2].astype(np.uint32) << 16)) hit = ids > 0 if not hit.any(): return None # The sentinel has to be above every packed depth, not np.inf: these # are integers now, and inf would force the comparison into float. nearest = int(np.argmin(np.where(hit, d, DEPTH_MAX + 1))) return int(ids.flat[nearest]) - 1 def delete(self) -> None: if self.fbo: glDeleteFramebuffers(1, [self.fbo]); self.fbo = 0 if self.color: glDeleteRenderbuffers(1, [self.color]); self.color = 0 if self.depth_color: glDeleteRenderbuffers(1, [self.depth_color]); self.depth_color = 0 if self.depth: glDeleteRenderbuffers(1, [self.depth]); self.depth = 0 self.w = self.h = 0 class ProgramBuffers: """The baked program's GPU buffers - one per baked part, keyed by name. The tagged trajectory (one normally, two in foam mode) and the dwell markers are separate VBOs with separate palettes that merely share a format and a shader, so they are separate buffers rather than one. Owning the dict is what lets the drawing part and the :class:`Picker` share exactly the geometry that is resident, and what keeps the choice of shader with the buffers it applies to. The shader *programs* are still the renderer's - they are shared with the rest of the scene - so each draw is handed the renderer to fetch them from. """ def __init__(self) -> None: #: part name -> Trajectory/CategoryBuffers self.buffers: dict[str, Any] = {} def upload(self, parts: Sequence[dict[str, Any]]) -> None: """Upload the baked program (from glcanon_bake.bake_program). Each part gets its own buffer, keyed by name. A part with no chain table - the dwell markers - draws as one ``glDrawArrays`` in its own primitive mode. A part that fell back to the per-vertex-colour format goes to a :class:`CategoryBuffers`. """ seen: set[str] = set() kinds = {"program_array": ProgramArrayBuffers, "trajectory": TrajectoryBuffers} for part in parts: name = part["name"] seen.add(name) kind = part.get("kind") want = kinds.get(kind, CategoryBuffers) buf = self.buffers.get(name) if not isinstance(buf, want): if buf is not None: buf.delete() buf = want() self.buffers[name] = buf if kind == "program_array": buf.upload(part["planes"], part["attrs"], part.get("palettes", ()), hide_cat=part.get("hide_cat", -1), last_drawn_kind=part.get("last_drawn_kind", PALETTE_SIZE - 1), mode=primitive_mode(part.get("mode")), spans=part.get("spans"), plane_offsets=part.get("plane_offsets", ())) elif kind == "trajectory": empty = np.empty(0, dtype=np.int32) buf.upload(part["verts"], part.get("firsts", empty), part.get("counts", empty), part["ranges"], part["palette"], hide_cat=part.get("hide_cat", -1), mode=primitive_mode(part.get("mode"))) else: buf.upload(part["verts"], part["ranges"]) for name in list(self.buffers): if name not in seen: self.buffers.pop(name).delete() def draw(self, renderer: GlCanonRenderer, mvp: Any, show_rapids: bool = True, alpha: float = 1.0) -> None: """Draw the whole program: the trajectory, then the dwell markers. The trajectory is one draw whatever its mix of categories; the shader colours each segment from the palette and rejects the rapids when they are hidden. Each buffer nominates which of its own categories the show-rapids toggle hides, so a buffer that nominates none is unaffected by it. """ for buf in self.buffers.values(): buf.begin(renderer, mvp, alpha, show_rapids) buf.draw() def draw_line(self, renderer: GlCanonRenderer, mvp: Any, lineno: int | None, color: Sequence[float]) -> None: """Draw only the spans belonging to source line ``lineno``, flat. Sets no depth or line-width state. The highlight overlaps the geometry it duplicates and needs ``GL_LEQUAL`` and a wider line to win the depth tie, but that belongs to the scope of the part that wants it - here it would apply to every caller and sit inside a draw. """ for buf in self.buffers.values(): buf.begin_override(renderer, mvp, color) buf.draw_line(lineno) def draw_ids(self, renderer: GlCanonRenderer, mvp: Any, show_rapids: bool = True) -> None: """Draw the program with each segment's source line number as colour. The same buffers the scene draws from, so what is pickable cannot drift from what is drawn. Meant for an offscreen target - see :meth:`PickTarget.offscreen`. """ for buf in self.buffers.values(): buf.begin_ids(renderer, mvp, show_rapids) buf.draw() glBindVertexArray(0) glUseProgram(0) def delete(self) -> None: for buf in self.buffers.values(): buf.delete() self.buffers.clear() class GlCanonRenderer: """The scene's shared GL resources, and the registry that releases them. Two jobs, and deliberately no third: * **A cache of what is genuinely shared** - the five shader programs, the offscreen pick target, the scratch and flat buffers behind :meth:`draw_line_array` and :meth:`draw_flat_array`, and the cone mesh behind :meth:`draw_cone`. Parts that rebuild their geometry every frame (grid, axes, extents, limits, Hershey labels) hand it over as a vertex array and keep no GPU state. * **A lifetime registry.** :meth:`register` takes anything with a ``delete()``, so one :meth:`delete` still releases every GL object on context loss or reload. That is what lets a part own the buffer it draws from - :class:`ProgramBuffers`, :class:`BackplotRing` - without the renderer owning the policy for drawing it. It holds no feature's resident geometry and exposes no per-feature upload or draw. Owning a GL resource used to require living here, because this was the only object with a ``delete()``; the registry is what separates the two. Programs are created lazily on first draw so the object can be constructed before a context is current. """ def __init__(self) -> None: # Every one of these is created on first use, once a context exists. self._caps: GLCaps | None = None #: The drawable size in pixels, told to us by whoever called #: glViewport. Quad expansion is a screen-space operation and needs it; #: (0, 0) means "not told yet", and the expanded path declines rather #: than guessing or spending a glGetIntegerv round-trip per draw. self._viewport: tuple[int, int] = (0, 0) self._line: LineProgram | None = None self._wide_line: WideLineProgram | None = None self._wide_prog_array: WideProgramArrayProgram | None = None self._traj: TrajectoryProgram | None = None self._cone: ConeProgram | None = None self._pick: PickProgram | None = None self._traj_pick: TrajectoryPickProgram | None = None self._prog_array: ProgramArrayProgram | None = None self._prog_array_pick: ProgramArrayPickProgram | None = None self._pick_fbo: PickTarget | None = None #: reusable buffer for transient line arrays self._scratch: CategoryBuffers | None = None #: reusable buffer for flat triangle arrays self._flat: CategoryBuffers | None = None #: MeshBuffers for the tool cone self._cone_mesh: MeshBuffers | None = None #: registered resources, released by delete(). Anything with a #: ``delete()`` - hence ``Any`` rather than a protocol the callers #: would have to import. self._owned: list[Any] = [] # -- capability record ------------------------------------------------- @property def caps(self) -> GLCaps: """What the live context can do - the one place the scene reads it. Probed on first access rather than in ``__init__`` because this object is deliberately constructible before a context is current (the shader programs below are lazy for the same reason). First access is the first program creation, which is inside the first frame, so every draw sees a populated record. """ if self._caps is None: self._caps = active_caps() return self._caps # -- lifetime registry ------------------------------------------------- def register(self, resource: Any) -> Any: """Take responsibility for releasing ``resource`` on :meth:`delete`. Anything with a ``delete()``. This is what lets a feature own its own GPU buffer outright and still be torn down by one call on context loss or reload, so that owning a GL resource no longer drags the policy for drawing it onto the renderer. It is a lifetime list and nothing more - no dispatch, no draw order, no lifecycle hooks. Returns the resource, so a caller can register and keep in one line. """ self._owned.append(resource) return resource # -- viewport ---------------------------------------------------------- def set_viewport(self, width: int, height: int) -> None: """Record the drawable size, alongside the caller's own glViewport. The quad-expanded line path works in pixels and needs this. Told rather than queried: a glGetIntegerv(GL_VIEWPORT) per draw is a pipeline stall, and the caller already knows the number it just passed to GL. """ self._viewport = (int(width), int(height)) @property def viewport(self) -> tuple[int, int]: return self._viewport def expansion_width(self) -> float: """The width the quad-expanding buffers should draw at, or 0. Zero whenever the driver already granted what the scene asked for, or the viewport is unknown - in both cases the plain ``GL_LINES`` path is correct and cheaper. """ if self._viewport[0] <= 0 or self._viewport[1] <= 0: return 0.0 return pending_line_expansion() # -- lazy program/resource creation ------------------------------------ def line_program(self) -> LineProgram: if self._line is None: self._line = LineProgram() return self._line def wide_line_program(self) -> WideLineProgram: if self._wide_line is None: self._wide_line = WideLineProgram() return self._wide_line def wide_program_array_program(self) -> WideProgramArrayProgram: if self._wide_prog_array is None: self._wide_prog_array = WideProgramArrayProgram() return self._wide_prog_array def cone_program(self) -> ConeProgram: if self._cone is None: self._cone = ConeProgram() return self._cone def traj_program(self) -> TrajectoryProgram: if self._traj is None: self._traj = TrajectoryProgram() return self._traj def pick_program(self) -> PickProgram: if self._pick is None: self._pick = PickProgram() return self._pick def traj_pick_program(self) -> TrajectoryPickProgram: if self._traj_pick is None: self._traj_pick = TrajectoryPickProgram() return self._traj_pick def program_array_program(self) -> ProgramArrayProgram: if self._prog_array is None: self._prog_array = ProgramArrayProgram() return self._prog_array def program_array_pick_program(self) -> ProgramArrayPickProgram: if self._prog_array_pick is None: self._prog_array_pick = ProgramArrayPickProgram() return self._prog_array_pick def pick_target(self, w: int, h: int) -> PickTarget: """The offscreen pick target, created once and sized to the window.""" if self._pick_fbo is None: self._pick_fbo = PickTarget() self._pick_fbo.ensure(int(w), int(h)) return self._pick_fbo # -- transient line geometry (grid/axes/extents/limits/labels) --------- def draw_line_array(self, mvp: Any, verts: WideVerts, alpha: float = 1.0) -> None: """Draw an interleaved (N,8) line array through the line shader. Sets no line-width state. A part wanting a line wider than the frame baseline puts that in its own scope, where it is visible and where it is restored - a shared drawing service that quietly widened and un-widened around one caller's draw is the pattern the scene rule exists to prevent. It does *read* the width the scene asked for: when the driver refused it, these arrays - grid, axes, extents, limits, labels, all of them a few thousand vertices at most - are drawn as quads instead of lines, so the width a part asked for is the width it gets on a driver that caps glLineWidth at 1.0. Reading state is not setting it; nothing here changes what the next caller sees. """ verts = np.ascontiguousarray(verts, dtype=np.float32) if verts.size == 0: return if self._scratch is None: self._scratch = CategoryBuffers() self._scratch.upload(verts) width = self.expansion_width() if width: wide = self.wide_line_program() wide.begin(mvp, alpha) wide.set_expansion(self._viewport, width) self._scratch.draw_wide() return line = self.line_program() line.begin(mvp, alpha) self._scratch.draw() def draw_flat_array(self, mvp: Any, verts: WideVerts, mode: GLEnum = GL_TRIANGLES, alpha: float = 1.0) -> None: """Draw an interleaved (N,8) array as flat primitives (default GL_TRIANGLES) through the line shader, which is a flat vertex-colour shader - used for the lathe-tool profile fill.""" verts = np.ascontiguousarray(verts, dtype=np.float32) if verts.size == 0: return if self._flat is None: self._flat = CategoryBuffers(mode=mode) self._flat.mode = mode self._flat.upload(verts) line = self.line_program() line.begin(mvp, alpha) self._flat.draw() # -- tool cone --------------------------------------------------------- def draw_cone(self, mvp: Any, normal_matrix: Any, color: Sequence[float], mesh_verts: MeshVerts | None = None, **lighting: Any) -> None: cone = self.cone_program() if self._cone_mesh is None: self._cone_mesh = MeshBuffers() if mesh_verts is not None: self._cone_mesh.upload(mesh_verts) cone.begin(mvp, normal_matrix, color, **lighting) self._cone_mesh.draw() def delete(self) -> None: # The capability record describes the context, so it goes with it: a # renderer revived against a new context re-probes rather than drawing # on the old one's answers. self._caps = None for resource in self._owned: resource.delete() del self._owned[:] if self._scratch: self._scratch.delete(); self._scratch = None if self._flat: self._flat.delete(); self._flat = None if self._cone_mesh: self._cone_mesh.delete(); self._cone_mesh = None if self._line: self._line.delete(); self._line = None if self._wide_line: self._wide_line.delete(); self._wide_line = None if self._wide_prog_array: self._wide_prog_array.delete(); self._wide_prog_array = None if self._traj: self._traj.delete(); self._traj = None if self._cone: self._cone.delete(); self._cone = None if self._pick: self._pick.delete(); self._pick = None if self._traj_pick: self._traj_pick.delete(); self._traj_pick = None if self._prog_array: self._prog_array.delete(); self._prog_array = None if self._prog_array_pick: self._prog_array_pick.delete(); self._prog_array_pick = None if self._pick_fbo: self._pick_fbo.delete(); self._pick_fbo = None class ProgramArrayBuffers: """A buffer in the program array's 20-byte format. One attribute buffer - the source line and the kind/tool word, shared - and one position buffer per drawn plane, each with its own VAO. Foam draws the same program twice and the per-vertex line, kind and tool data is stored once for both; on any other config there is simply one plane. No chain table. The discontinuities live in the vertex data as record kinds the shader rejects, so the whole program is one ``glDrawArrays`` over a contiguous range - which is also what lets the highlight spans be computed from the line column rather than carried alongside it. """ def __init__(self, mode: GLEnum = GL_LINE_STRIP) -> None: self.mode = mode self.attr_buffer = GLBuffer() self.plane_buffers: list[GLBuffer] = [] self.vaos: list[VertexArray] = [] #: Per plane, the same buffers presented as segment endpoint pairs for #: the quad-expanded path, and the (first_vertex, step) each is #: currently pointed at. Built on first use - on a driver that grants #: the width asked for, never. self.wide_vaos: list[VertexArray] = [] self._wide_keys: dict[int, tuple[int, int]] = {} self.count = 0 # vertices #: One palette per plane. Foam's two planes are the same program in #: different colours (``_xy`` and ``_uv``), so the palette is a #: property of the plane, not of the buffer - which is the one thing #: sharing the attribute array must not quietly flatten. self.palettes: list[Sequence[Sequence[float]]] = [] #: One Z offset per plane: where that plane is drawn, not what the #: program is. Foam's ``foam_z``/``foam_w`` are the only non-zero #: values today. Held here and applied by :meth:`_use` so that every #: pass this buffer can be drawn in - colour, ids, override - gets it #: from the same place; an offset in one pass and not another would #: make picking disagree with the screen while both looked correct. self.plane_offsets: list[float] = [] # This buffer's own kind codes: which the show-rapids toggle hides, # and where its drawn kinds stop. A buffer with no records - the dwell # markers - names its whole palette, so nothing it holds is ever taken # for one. self.hide_cat = -1 self.last_drawn_kind = PALETTE_SIZE - 1 #: (first_vertex, count) spans per source line, as parallel arrays #: sorted by line, or None. Searched rather than dict-indexed. May be #: supplied as a zero-argument callable, resolved on first use by #: :meth:`_resolve_spans` - which is how the program keeps an index #: only the highlight reads off the upload path. self.spans: Any = None #: The pass ``begin`` recorded, issued per plane by ``draw``. self._pass: tuple[Any, ...] | None = None def upload(self, planes: Sequence[Any], attrs: Any, palettes: Sequence[Sequence[Sequence[float]]] = (), hide_cat: int = -1, last_drawn_kind: int = PALETTE_SIZE - 1, mode: GLEnum | None = None, spans: Any = None, plane_offsets: Sequence[float] = ()) -> None: """Upload one attribute array and one position array per plane.""" if mode is not None: self.mode = mode attrs = np.ascontiguousarray(attrs, dtype=ATTR_DTYPE) self.count = int(len(attrs)) self.hide_cat = hide_cat self.last_drawn_kind = last_drawn_kind self.spans = spans blank = [(1.0, 1.0, 1.0, 1.0)] * PALETTE_SIZE self.palettes = [list(palettes[i]) if i < len(palettes) else blank for i in range(len(planes))] # A buffer that names no offsets draws exactly where its vertices are. self.plane_offsets = [ float(plane_offsets[i]) if i < len(plane_offsets) else 0.0 for i in range(len(planes))] while len(self.plane_buffers) < len(planes): self.plane_buffers.append(GLBuffer()) self.vaos.append(VertexArray()) while len(self.plane_buffers) > len(planes): self.plane_buffers.pop().delete() self.vaos.pop().delete() # The endpoint-pair VAOs point into the plane buffers, so they go with # them; the rest are rebuilt lazily against whatever is uploaded now. while len(self.wide_vaos) > len(planes): self.wide_vaos.pop().delete() self._wide_keys.clear() if not self.count: return self.attr_buffer.set_data(attrs) for i, plane in enumerate(planes): plane = np.ascontiguousarray(plane, dtype=PLANE_DTYPE) self.plane_buffers[i].set_data(plane) # Two configure calls on one VAO: the attribute-to-buffer binding # is captured per glVertexAttribPointer, so the second does not # displace the first. self.vaos[i].configure(self.plane_buffers[i], PROGRAM_PLANE_ATTRIBUTES, PLANE_DTYPE.itemsize) self.vaos[i].configure(self.attr_buffer, PROGRAM_ATTR_ATTRIBUTES, ATTR_DTYPE.itemsize) # The pass is recorded by ``begin`` and issued by ``draw``, rather than # set once and drawn, because each plane carries its own palette: the # shader state has to be re-established between the two draws. The call # pattern stays the one every other buffer here uses. def begin(self, renderer: GlCanonRenderer, mvp: Any, alpha: float = 1.0, show_rapids: bool = True) -> None: self._pass = ("draw", renderer, mvp, alpha, show_rapids) def begin_ids(self, renderer: GlCanonRenderer, mvp: Any, show_rapids: bool = True) -> None: self._pass = ("ids", renderer, mvp, show_rapids) def begin_override(self, renderer: GlCanonRenderer, mvp: Any, color: Sequence[float]) -> None: """One flat colour: the highlight. No hidden kind - a highlighted rapid draws while rapids are hidden. ``last_drawn_kind`` is *not* relaxed: the highlight overrides a toggle, not the structure. """ self._pass = ("override", renderer, mvp, color) def _plane_mvp(self, mvp: Any, plane: int) -> Any: """``mvp`` with this plane's Z offset folded in. A rigid translation along Z, which is what the offset is - so it belongs in the matrix rather than added to every vertex. Doing it here rather than in a uniform keeps the shaders inside the GL 3.3 core / GLES 3.1 intersection without a new one to verify. ``mvp @ translate(0, 0, dz)`` written out: that translation is the identity with ``[2][3] = dz``, so the product is ``mvp`` with its fourth column advanced by ``dz`` times its third. Spelled arithmetically rather than through ``glnav`` so the GL layer keeps its current imports. """ dz = (self.plane_offsets[plane] if plane < len(self.plane_offsets) else 0.0) if not dz: return mvp out = np.array(mvp, dtype=np.float64) out[:, 3] += out[:, 2] * dz return out def _use(self, plane: int, wide: float = 0.0) -> None: """Establish the recorded pass's shader state for one plane. ``wide`` is the quad expansion's line width in pixels, or 0 for the plain vertex stage. The ids pass never expands: what is pickable is the geometry, not the widened drawing of it, which is how it behaved when the width came from glLineWidth too. """ if self._pass is None: return what, renderer = self._pass[0], self._pass[1] mvp = self._plane_mvp(self._pass[2], plane) palette = self.palettes[plane] if plane < len(self.palettes) else () if what == "ids": renderer.program_array_pick_program().begin( mvp, hide_cat=-1 if self._pass[3] else self.hide_cat, last_drawn_kind=self.last_drawn_kind) return program = (renderer.wide_program_array_program() if wide else renderer.program_array_program()) if what == "override": program.begin(mvp, palette, hide_cat=-1, last_drawn_kind=self.last_drawn_kind) program.set_override_color(self._pass[3]) else: _w, _r, _m, alpha, show_rapids = self._pass program.begin(mvp, palette, alpha, hide_cat=-1 if show_rapids else self.hide_cat, last_drawn_kind=self.last_drawn_kind) if wide: program.set_expansion(renderer.viewport, wide) def _expansion_width(self) -> float: """The width a *body* draw of this buffer should quad-expand at, or 0. Non-zero only for a disjoint-segment buffer whose requested width the driver refused. GL_LINES here means the dwell markers, whose arms are a few hundred vertices and are meaningless at one pixel. GL_LINE_STRIP means the program trajectory, which is never expanded whatever the driver grants - see the WIDE_LINE_EXPAND note. """ if self.mode != GL_LINES or self._pass is None: return 0.0 return self._pass[1].expansion_width() def _wide_vao(self, plane: int, first: int, step: int) -> VertexArray: """This plane's endpoint-pair VAO, pointed at ``first`` with ``step`` vertices per segment (2 for GL_LINES, 1 for a strip). The plane buffer is bound twice, one vertex apart; the attribute buffer once, at the segment's END vertex, which is where the last-vertex convention lives on this path. """ while len(self.wide_vaos) <= plane: self.wide_vaos.append(VertexArray()) vao = self.wide_vaos[plane] key = (first, step) if self._wide_keys.get(plane) != key: pstride = PLANE_DTYPE.itemsize astride = ATTR_DTYPE.itemsize buf = self.plane_buffers[plane] vao.configure(buf, WIDE_PLANE_A_ATTRIBUTES, step * pstride, divisor=1, base=first * pstride) vao.configure(buf, WIDE_PLANE_B_ATTRIBUTES, step * pstride, divisor=1, base=(first + 1) * pstride) vao.configure(self.attr_buffer, WIDE_ATTR_B_ATTRIBUTES, step * astride, divisor=1, base=(first + 1) * astride) self._wide_keys[plane] = key return vao def draw(self) -> None: """One draw per plane, each over the whole contiguous vertex range.""" if not self.count: return width = self._expansion_width() for plane, vao in enumerate(self.vaos): self._use(plane, width) if width: wide = self._wide_vao(plane, 0, 2) wide.bind() glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, self.count // 2) wide.unbind() else: vao.bind() glDrawArrays(self.mode, 0, self.count) vao.unbind() def _resolve_spans(self) -> Any: """The span index, building it on first use if it was deferred. Replaces the callable with what it returned, so a program highlighted many times builds it once and one highlighted never builds it at all. """ if callable(self.spans): self.spans = self.spans() return self.spans def draw_line(self, lineno: int | None) -> None: """Draw only the spans belonging to source line ``lineno``. This is the highlight overlay, and unlike the body draw above it quad- expands whatever the mode: one source line is a handful of segments, and a highlight the user cannot see against the program is the one thing a 1px program most needs to keep. """ if not self.count or self.spans is None or lineno is None: return spans = self._resolve_spans() if spans is None: return keys, firsts, counts = spans lo = int(np.searchsorted(keys, lineno, side="left")) hi = int(np.searchsorted(keys, lineno, side="right")) if lo == hi: return width = 0.0 if self._pass is None else self._pass[1].expansion_width() step = 2 if self.mode == GL_LINES else 1 for plane, vao in enumerate(self.vaos): self._use(plane, width) if width: for i in range(lo, hi): first, count = int(firsts[i]), int(counts[i]) segments = count // 2 if step == 2 else count - 1 if segments <= 0: continue wide = self._wide_vao(plane, first, step) wide.bind() glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, segments) wide.unbind() else: vao.bind() for i in range(lo, hi): glDrawArrays(self.mode, int(firsts[i]), int(counts[i])) vao.unbind() def delete(self) -> None: for vao in self.vaos: vao.delete() for vao in self.wide_vaos: vao.delete() for buf in self.plane_buffers: buf.delete() del self.vaos[:] del self.wide_vaos[:] self._wide_keys.clear() del self.plane_buffers[:] self.attr_buffer.delete() self.count = 0 class TrajectoryBuffers: """A buffer in the shared 16-byte vertex format, drawn through the trajectory shader. Holds the vertex buffer, an optional chain table :meth:`draw` walks, the per-line spans the highlight pass draws, and the colour palette the shader indexes with each vertex's category. The program supplies a chain table and is drawn as connected strips - one of these replaces the three per-category buffers, which is what lets a point shared by two segments be stored once. A buffer of disjoint segments (the dwell arms, the foam backplot) supplies no chain table and is drawn as one ``glDrawArrays`` in its own ``mode``, rather than manufacturing a two-entry chain per pair. """ def __init__(self, mode: GLEnum = GL_LINE_STRIP) -> None: self.mode = mode self.buffer = GLBuffer() self.vao = VertexArray() self.count = 0 # vertices self.firsts: npt.NDArray[np.int32] = np.empty(0, dtype=np.int32) self.counts: npt.NDArray[np.int32] = np.empty(0, dtype=np.int32) self.line_ranges: LineRanges = {} self.palette: Sequence[Sequence[float]] = ( [(1.0, 1.0, 1.0, 1.0)] * PALETTE_SIZE) # Which of this buffer's own categories the show-rapids toggle hides. # -1 means "this buffer has none", which is what keeps another buffer's # palette slot 0 from inheriting the program's rapid behaviour. self.hide_cat = -1 self._configured = False def upload(self, verts: TrajectoryVerts, firsts: Any, counts: Any, line_ranges: LineRanges | None = None, palette: Sequence[Sequence[float]] | None = None, hide_cat: int = -1, mode: GLEnum | None = None) -> None: if mode is not None: self.mode = mode verts = np.ascontiguousarray(verts, dtype=np.float32) self.count = 0 if verts.size == 0 else verts.shape[0] self.firsts = np.ascontiguousarray(firsts, dtype=np.int32) self.counts = np.ascontiguousarray(counts, dtype=np.int32) self.line_ranges = line_ranges or {} self.hide_cat = hide_cat if palette is not None: self.palette = palette if self.count: self.buffer.set_data(verts) if not self._configured: self.vao.configure(self.buffer, TRAJ_ATTRIBUTES, TRAJ_VERTEX_STRIDE) self._configured = True def begin(self, renderer: GlCanonRenderer, mvp: Any, alpha: float = 1.0, show_rapids: bool = True) -> None: """Configure the trajectory shader for this buffer's own draw. The buffer nominates which of its categories the show-rapids toggle hides, so a buffer nominating none is unaffected by it. The shader programs stay shared, hence ``renderer``. """ traj = renderer.traj_program() traj.begin(mvp, self.palette, alpha, hide_cat=-1 if show_rapids else self.hide_cat) def begin_ids(self, renderer: GlCanonRenderer, mvp: Any, show_rapids: bool = True) -> None: """Configure the pick shader, which writes the line number as colour. Rapids follow their visibility here as they do on screen, so a hidden rapid cannot be selected by clicking where it would have been. """ renderer.traj_pick_program().begin( mvp, hide_cat=-1 if show_rapids else self.hide_cat) def begin_override(self, renderer: GlCanonRenderer, mvp: Any, color: Sequence[float]) -> None: """Configure the shader to draw this buffer in one flat colour.""" traj = renderer.traj_program() # No hidden category: a highlighted rapid draws even while rapids are # hidden. That held before because ``u_use_override`` short-circuited # the category test; leaving it at -1 says it a second way, so the # behaviour does not rest on the override alone. traj.begin(mvp, self.palette, hide_cat=-1) traj.set_override_color(color) def draw(self) -> None: """One draw for the whole buffer, whatever its mix of categories. With a chain table that is a single multi-draw over the chains; with none it is a single ``glDrawArrays`` of the whole vertex range. The chain-table branch is a plain loop rather than glMultiDrawArrays, which is core on desktop GL but only an extension on GLES that Mesa's v3d is not required to have. Looping unconditionally costs nothing measurable *because this path does not carry the program*: the program array is one contiguous range with no chain table at all (record-kind vertices took its place), and the dwell markers and the live backplot both supply none and take the single-draw branch below. What reaches the loop is at most a few thousand vertices - and one path means the desktop corpus exercises the same code a Pi runs. """ if not self.count: return self.vao.bind() if len(self.firsts): for first, count in zip(self.firsts, self.counts): glDrawArrays(self.mode, int(first), int(count)) else: glDrawArrays(self.mode, 0, self.count) self.vao.unbind() def draw_line(self, lineno: int | None) -> None: """Draw only the spans belonging to source line ``lineno``.""" spans = self.line_ranges.get(lineno) if not spans: return self.vao.bind() for first, count in spans: glDrawArrays(self.mode, first, count) self.vao.unbind() def delete(self) -> None: self.vao.delete() self.buffer.delete() class CategoryBuffers: """One baked draw-category (VBO + VAO + vertex count) drawn as GL_LINES. Wraps an interleaved float32 vertex array from rs274.glcanon_bake so a draw is a single :meth:`draw`. ``line_ranges`` (line-number -> [(first, count)]) is retained so a highlight pass can redraw only a selected line's spans. Nothing persistent uses this any more. The program, the dwell markers and the live backplot all carry a palette index in the shared 16-byte vertex and draw through the trajectory shader. What is left here is the geometry that genuinely does need a colour per vertex: the transient grid, axes, extents and Hershey-label arrays, which are rebuilt every frame from live view state, and the lathe-tool profile fill. Their colours are view-dependent - a label's colour depends on whether it is past a soft limit - so they do not reduce to a small palette, and packing indices for them each frame would cost more CPU than the bandwidth it saved. It also serves as the landing place for a part whose colours overflowed its palette and fell back to this format. """ def __init__(self, mode: GLEnum = GL_LINES) -> None: self.mode = mode self.buffer = GLBuffer() self.vao = VertexArray() #: The same buffer presented as segment endpoint pairs, for the #: quad-expanded path. Built on first use, which on a driver that #: grants the width asked for is never. self.wide_vao: VertexArray | None = None self.count = 0 self.line_ranges: LineRanges = {} self._configured = False def upload(self, verts: WideVerts, line_ranges: LineRanges | None = None) -> None: verts = np.ascontiguousarray(verts, dtype=np.float32) self.count = 0 if verts.size == 0 else verts.shape[0] self.line_ranges = line_ranges or {} if self.count: self.buffer.set_data(verts) if not self._configured: self.vao.configure(self.buffer) self._configured = True def _segment_vao(self) -> VertexArray: """The VAO presenting this buffer as GL_LINES endpoint pairs. One instance per segment, the buffer bound twice - at vertex 0 and at vertex 1 - with a stride of two vertices. No vertex data is duplicated; the buffer's own bytes are read twice. """ if self.wide_vao is None: self.wide_vao = VertexArray() step = 2 * VERTEX_STRIDE self.wide_vao.configure(self.buffer, WIDE_LINE_A_ATTRIBUTES, step, divisor=1, base=0) self.wide_vao.configure(self.buffer, WIDE_LINE_B_ATTRIBUTES, step, divisor=1, base=VERTEX_STRIDE) return self.wide_vao def draw_wide(self) -> None: """Draw as quads, one instance per GL_LINES segment. Only meaningful for ``GL_LINES`` content; the caller decides, because the caller is the one that knows the driver refused the width. """ if not self.count: return vao = self._segment_vao() vao.bind() glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, self.count // 2) vao.unbind() def begin(self, renderer: GlCanonRenderer, mvp: Any, alpha: float = 1.0, show_rapids: bool = True) -> None: """Configure the line shader for this buffer's own draw. Colour is per vertex here, so there is no palette, no category and therefore nothing for the show-rapids argument to select: it is accepted and ignored, which is the whole of this kind's answer. """ renderer.line_program().begin(mvp, alpha) def begin_ids(self, renderer: GlCanonRenderer, mvp: Any, show_rapids: bool = True) -> None: """Configure the pick shader, which writes the line number as colour. No categories here, so nothing for show-rapids to hide - see :meth:`begin`. """ renderer.pick_program().set_mvp(mvp) def begin_override(self, renderer: GlCanonRenderer, mvp: Any, color: Sequence[float]) -> None: """Configure the shader to draw this buffer in one flat colour.""" line = renderer.line_program() line.begin(mvp) line.set_override_color(color) def draw(self) -> None: if not self.count: return self.vao.bind() glDrawArrays(self.mode, 0, self.count) self.vao.unbind() def draw_line(self, lineno: int | None) -> None: """Draw only the spans belonging to source line ``lineno`` (highlight).""" spans = self.line_ranges.get(lineno) if not spans: return self.vao.bind() for first, count in spans: glDrawArrays(self.mode, first, count) self.vao.unbind() def delete(self) -> None: self.vao.delete() if self.wide_vao is not None: self.wide_vao.delete() self.wide_vao = None self.buffer.delete() class BackplotRing: """The live backplot's growable ring VBO, and everything resident in it. Uploaded incrementally with glBufferSubData as the logger appends points; grows by doubling. A grow orphans the store (contents lost), so a frame that grows re-uploads the whole trail. ``mode`` is GL_LINE_STRIP (normal) or GL_LINES (foam), matching the legacy positionlogger draw. The trail is its own buffer, separate from the program's, and normally holds the shared 16-byte vertex with a palette index - ``indexed`` - drawn through the trajectory shader. It falls back to the per-vertex-colour layout if a palette could not hold every colour the logger emitted. **One object answers "how much of the trail is already here?"** Every piece of state that question needs is here: how many vertices are resident, in what layout, how many the store can hold, which foam mode they were built in, and the palette their indices refer to. Split between the drawing part and the renderer, as it was, the answer had to be assembled from two halves by the caller and policed by an exception on the way back in. """ def __init__(self, palette: Any = None) -> None: """``palette`` is a ``glcanon_bake.ColorPalette``, supplied by the caller rather than constructed here so this module keeps its independence from the baking module - which is also why it is ``Any`` rather than an import.""" self.buffer = GLBuffer() self.vao = VertexArray() self.capacity = 0 # vertices the store can hold self.count = 0 # vertices to draw self.mode: GLEnum = GL_LINE_STRIP self.indexed = True # 16-byte palette-indexed layout #: foam mode the resident vertices were built in. An int rather than a #: bool because it is compared with the logger's own flag. self.is_xyuv: int = 0 # Append-only across every frame of the session, deliberately: only the # changed tail is re-uploaded, so vertices already resident keep the # index they were written with. Rebuilding this - even on a full # re-upload - would risk renumbering a colour that resident vertices # still refer to. It holds at most the six colours the C picks from, so # it never needs pruning. Supplied by the caller rather than constructed # here, so this module keeps its independence from the baking module. self.palette = palette #: the padded list the shader indexes self.shader_palette: list[tuple[float, ...]] | None = None self._configured = False @property def stride(self) -> int: return TRAJ_VERTEX_STRIDE if self.indexed else VERTEX_STRIDE @property def vertices_per_point(self) -> int: """Foam draws each logger point as a segment: two vertices, not one.""" return 2 if self.is_xyuv else 1 @property def npts(self) -> int: """Logger points resident, derived from the vertices and the layout. Not stored: it is the vertex count over the layout's vertices-per-point, and a second field holding it is a second thing to keep in step. """ return self.count // self.vertices_per_point def resident_points(self, npts: int, is_xyuv: int) -> int: """How many leading logger points the next frame may keep. 0 means convert and upload the whole trail. Four conditions force that, all of them read from this object's own state: 1. the resident vertices are in the wide per-vertex-colour layout, so a palette-indexed tail cannot go into them; 2. holding ``npts`` would grow the store, and a grow orphans it; 3. the foam mode changed, so the vertices mean something else; 4. the source shrank - a clear, or the C ring dropping its oldest - so what is resident is not a prefix of what is being asked for. Otherwise every resident point but the last survives. The last is always re-converted because the C moves it in place (``s->p[s->npts-1]``) while the tool runs along a colinear stretch, so it is dirty every frame. """ if is_xyuv != self.is_xyuv: # 3 return 0 if not self.indexed: # 1 return 0 resident = self.npts if npts < resident: # 4 return 0 if npts * self.vertices_per_point > self.capacity: # 2 return 0 return max(resident - 1, 0) def write(self, verts: TrajectoryVerts | WideVerts, first_point: int, is_xyuv: int) -> None: """Write ``verts`` into the store starting at logger point ``first_point``. ``verts`` is exactly what should be transferred - the caller has already narrowed it to the tail :meth:`resident_points` allowed - and the layout is derived here, once, from the array itself: a palette overflow that widened every vertex is handled by re-configuring the VAO rather than by being announced. No guard is needed against a tail at an offset the store cannot accept. The offset came from :meth:`resident_points`, which read the same capacity and layout this acts on; there is no second party to disagree with. """ verts = np.ascontiguousarray(verts, dtype=np.float32) sent = 0 if verts.size == 0 else verts.shape[0] indexed = verts.ndim == 2 and verts.shape[1] == TRAJ_FLOATS_PER_VERTEX vpp = 2 if is_xyuv else 1 first_vertex = max(int(first_point), 0) * vpp total = first_vertex + sent self.is_xyuv = is_xyuv self.mode = GL_LINES if is_xyuv else GL_LINE_STRIP self.shader_palette = (self.palette.padded() if indexed and self.palette is not None else None) # A format change invalidates the resident contents as surely as a # grow does: the same bytes mean something else at the new stride. self.ensure(total, indexed) if sent: self.buffer.update_sub(first_vertex * self.stride, verts) self.count = total def invalidate(self) -> None: """Force the next frame to convert and upload the whole trail. Residency only. The palette deliberately survives: resident vertices refer to palette indices, and a clear followed by new points must not renumber a colour a surviving vertex still uses. """ self.count = 0 def ensure(self, total: int, indexed: bool = True) -> bool: """Grow to hold >= ``total`` vertices in the given layout. Returns True if the resident contents were invalidated - by a grow, or by a layout change, which reinterprets every resident byte and so must force the same full re-upload a grow does. """ changed = indexed != self.indexed if changed: self.indexed = indexed self._configured = False self.capacity = 0 if total <= self.capacity: return False newcap = max(total, self.capacity * 2, 1024) self.buffer.orphan(newcap * self.stride) self.capacity = newcap if not self._configured: if self.indexed: self.vao.configure(self.buffer, TRAJ_ATTRIBUTES, TRAJ_VERTEX_STRIDE) else: self.vao.configure(self.buffer) self._configured = True return True def draw(self, renderer: GlCanonRenderer, mvp: Any, alpha: float = 1.0) -> None: """Draw the resident trail, selecting its own shader for its layout. Sets no depth or line-width state: the trail wants a wider line than the baseline, and that belongs in the scope of the part that wants it. """ if self.count < 2: return if self.indexed: # The trail nominates no hidden category, so a point whose palette # index happens to equal the program's rapid code is still drawn # with rapids off. renderer.traj_program().begin( mvp, self.shader_palette or [], alpha, hide_cat=-1) else: renderer.line_program().begin(mvp, alpha) self._draw_arrays() def _draw_arrays(self) -> None: self.vao.bind() glDrawArrays(self.mode, 0, self.count) self.vao.unbind() def delete(self) -> None: self.vao.delete() self.buffer.delete() # --------------------------------------------------------------------------- # Glyph-atlas overlay text. # # Replaces the legacy glBitmap/glDrawPixels Pango font (one display list per # glyph) with a single texture atlas drawn as textured quads in an # orthographic overlay pass. Pango/Cairo rasterises each glyph once into the # atlas; per-glyph metrics (size, advance, bearing) are kept so text lays out # with the same spacing as the legacy path. The same pass draws the semi- # transparent overlay background and the home/limit icons (from the existing # 1-bit bitmap arrays) as textured quads. # # It is built on the shader/buffer/VAO wrappers above and owns its own GL # objects the same way the rest of this module does; nothing here reaches into # GlCanonDraw policy, and it sets no GL state of its own (the caller's overlay # pass owns depth and blend). # Overlay vertex: screen-pixel position (2f) + atlas uv (2f). Its own # layout, distinct from the two vertex formats rs274.glcanon_bake names: this # pass draws in screen pixels and never reaches the world shaders. OverlayVerts = Sequence[tuple[float, float, float, float]] #: (width, height) of the viewport, in pixels. Screen = tuple[int, int] #: rgba, 0..1. Color = Sequence[float] _OVERLAY_STRIDE = 4 * 4 _OVERLAY_ATTRS = ((0, 2, 0), (1, 2, 2 * 4)) TEXT_VERTEX_SHADER = """ layout(location = 0) in vec2 in_pos; // pixels, origin bottom-left layout(location = 1) in vec2 in_uv; uniform vec2 u_screen; // viewport (width, height) in pixels out vec2 v_uv; void main() { vec2 ndc = vec2(in_pos.x / u_screen.x * 2.0 - 1.0, in_pos.y / u_screen.y * 2.0 - 1.0); gl_Position = vec4(ndc, 0.0, 1.0); v_uv = in_uv; } """ TEXT_FRAGMENT_SHADER = """ in vec2 v_uv; uniform sampler2D u_atlas; uniform vec4 u_color; uniform bool u_textured; // false -> flat fill (overlay background) out vec4 frag_color; void main() { float a = u_textured ? texture(u_atlas, v_uv).r : 1.0; frag_color = vec4(u_color.rgb, u_color.a * a); } """ class GlyphAtlas: """A Pango-rasterised glyph atlas plus the shader to draw overlay quads. Built by :func:`build_atlas` (via glnav.use_pango_font). Holds one alpha texture with the glyphs packed in a grid, per-glyph metrics, and a dynamic VBO reused for each string/quad. Rendering assumes an orthographic, screen- pixel coordinate space (origin bottom-left), matching the legacy overlay. """ def __init__(self, char_width: int, line_space: int, descent: int) -> None: self.char_width = char_width self.line_space = line_space self.descent = descent self.texture: int = 0 self.tex_w = self.tex_h = 0 #: codepoint -> dict(u0, v0, u1, v1, w, h, advance) self.glyphs: dict[int, dict[str, float]] = {} #: key -> (texture, w, h) for home/limit icons self._icons: dict[Any, tuple[int, int, int]] = {} # Created together on the first draw, once a context exists. self._program: ShaderProgram | None = None self._buffer: GLBuffer | None = None self._vao: VertexArray | None = None # -- lazy GL resources ------------------------------------------------- def _prog(self) -> ShaderProgram: if self._program is None: self._program = ShaderProgram(TEXT_VERTEX_SHADER, TEXT_FRAGMENT_SHADER) self._buffer = GLBuffer() self._vao = VertexArray() self._vao.configure(self._buffer, _OVERLAY_ATTRS, _OVERLAY_STRIDE) return self._program def _draw_array(self, verts: OverlayVerts | npt.NDArray[np.float32], color: Color, textured: bool, screen: Screen, texture: int | None = None) -> None: prog = self._prog() prog.use() glUniform2f(prog.uniform("u_screen"), screen[0], screen[1]) r, g, b, a = color prog.set_vec4("u_color", r, g, b, a) prog.set_bool("u_textured", textured) if textured: glActiveTexture(GL_TEXTURE0) glBindTexture(GL_TEXTURE_2D, texture or self.texture) prog.set_int("u_atlas", 0) verts = np.asarray(verts, dtype=np.float32) self._buffer.set_data(verts, usage=GL_DYNAMIC_DRAW) self._vao.bind() glDrawArrays(GL_TRIANGLES, 0, len(verts)) # one vertex per row self._vao.unbind() glUseProgram(0) # -- public draw calls ------------------------------------------------- def draw_quad(self, x0: float, y0: float, x1: float, y1: float, color: Color, screen: Screen) -> None: """Flat-filled quad (overlay background).""" verts = _quad(x0, y0, x1, y1, 0, 0, 0, 0) self._draw_array(verts, color, False, screen) def string_quads(self, s: str, x: float, y: float) -> list[ tuple[float, float, float, float]]: """Build atlas-textured triangles for ``s`` with the pen at (x, y). ``y`` is the text-line origin; each glyph occupies screen y in ``[y - descent, y - descent + h]`` and advances the pen by its width, reproducing the legacy raster placement. """ verts: list[tuple[float, float, float, float]] = [] pen = x for ch in s: g = self.glyphs.get(ord(ch)) if g is None: pen += self.char_width continue if g["w"] and g["h"]: y0 = y - self.descent y1 = y0 + g["h"] verts.extend(_quad(pen, y0, pen + g["w"], y1, g["u0"], g["v1"], g["u1"], g["v0"])) pen += g["advance"] return verts def draw_string(self, s: str, x: float, y: float, color: Color, screen: Screen) -> None: verts = self.string_quads(s, x, y) if verts: self._draw_array(verts, color, True, screen) # -- home/limit icons -------------------------------------------------- def _icon_texture(self, key: Any, data: Sequence[int], w: int, h: int) -> int: """(Build once and) return the coverage texture for a 1-bit icon. ``data`` is the legacy glBitmap byte array: ``ceil(w/8)`` bytes per row, MSB-first, first row at the image bottom (OpenGL image order). It expands to a GL_R8 coverage texture (255 where a bit is set) so the overlay shader draws it in ``u_color`` exactly where glBitmap set pixels. """ cached = self._icons.get(key) if cached is not None: return cached[0] row_bytes = (w + 7) // 8 img = np.zeros((h, w), dtype=np.uint8) for row in range(h): bits = 0 for b in range(row_bytes): bits = (bits << 8) | data[row * row_bytes + b] top = row_bytes * 8 - 1 for x in range(w): if bits & (1 << (top - x)): img[row, x] = 255 tex = glGenTextures(1) glBindTexture(GL_TEXTURE_2D, tex) glPixelStorei(GL_UNPACK_ALIGNMENT, 1) glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, w, h, 0, GL_RED, GL_UNSIGNED_BYTE, img.tobytes()) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) self._icons[key] = (tex, w, h) return tex def draw_icon(self, key: Any, data: Sequence[int], x: float, y: float, w: int, h: int, color: Color, screen: Screen) -> None: """Draw a 1-bit icon as a textured quad with its bottom-left at (x, y). Matches the legacy ``glBitmap(w, h, ...)`` placement: the texture is stored bottom-up, so screen-bottom (y) samples the first data row. """ tex = self._icon_texture(key, data, w, h) verts = _quad(x, y, x + w, y + h, 0.0, 0.0, 1.0, 1.0) self._draw_array(verts, color, True, screen, texture=tex) def delete(self) -> None: if self.texture: glDeleteTextures([self.texture]); self.texture = 0 for tex, _w, _h in self._icons.values(): glDeleteTextures([tex]) self._icons.clear() if self._program: self._program.delete(); self._program = None if self._buffer: self._buffer.delete() if self._vao: self._vao.delete() def _quad(x0: float, y0: float, x1: float, y1: float, u0: float, v0: float, u1: float, v1: float) -> list[tuple[float, float, float, float]]: """Two triangles (6 verts) for the rectangle, with the given uv corners.""" return [ (x0, y0, u0, v0), (x1, y0, u1, v0), (x1, y1, u1, v1), (x0, y0, u0, v0), (x1, y1, u1, v1), (x0, y1, u0, v1), ] def build_atlas(font: str, start: int, count: int) -> GlyphAtlas: """Rasterise glyphs ``start..start+count`` of ``font`` into a GlyphAtlas. Mirrors glnav.use_pango_font's Pango/Cairo setup so metrics match, but packs the glyphs into one GL_R8 texture instead of per-glyph display lists. Returns the GlyphAtlas. """ import gi gi.require_version('Pango', '1.0') gi.require_version('PangoCairo', '1.0') from gi.repository import Pango from gi.repository import PangoCairo import cairo font_desc = Pango.FontDescription(font) surface = cairo.ImageSurface(cairo.FORMAT_A8, 256, 256) context = cairo.Context(surface) pango_context = PangoCairo.create_context(context) layout = PangoCairo.create_layout(context) fontmap = PangoCairo.font_map_get_default() loaded = fontmap.load_font(fontmap.create_context(), font_desc) layout.set_font_description(font_desc) metrics = loaded.get_metrics() # int metrics, matching the legacy use_pango_font return so callers that do # integer pixel arithmetic (e.g. glRasterPos2i) keep working. descent = int(metrics.get_descent() / Pango.SCALE) line_space = int((metrics.get_ascent() + metrics.get_descent()) / Pango.SCALE) char_width = int(metrics.get_approximate_char_width() / Pango.SCALE) # First pass: rasterise every glyph, record its bitmap and size. bitmaps = {} max_w = max_h = 1 for i in range(count): cp = start + i layout.set_text(chr(cp), -1) w, h = layout.get_size() w, h = int(w / Pango.SCALE), int(h / Pango.SCALE) w = max(0, min(w, 256)) h = max(0, min(h, 256)) surface.flush() # clear context.save(); context.set_operator(cairo.OPERATOR_CLEAR) context.paint(); context.restore() context.save(); context.set_operator(cairo.OPERATOR_SOURCE) context.set_source_rgba(1, 1, 1, 1); context.move_to(0, 0) PangoCairo.update_context(context, pango_context) PangoCairo.show_layout(context, layout) context.restore() surface.flush() stride = surface.get_stride() buf = bytes(surface.get_data()) glyph = np.zeros((h, w), dtype=np.uint8) for row in range(h): base = row * stride glyph[row, :] = np.frombuffer(buf[base:base + w], dtype=np.uint8) bitmaps[cp] = (glyph, w, h, char_width) max_w = max(max_w, w) max_h = max(max_h, h) # Pack into a grid atlas. cols = 16 rows = (count + cols - 1) // cols cell_w, cell_h = max_w + 1, max_h + 1 atlas_w, atlas_h = cols * cell_w, rows * cell_h atlas = np.zeros((atlas_h, atlas_w), dtype=np.uint8) result = GlyphAtlas(char_width, line_space, descent) for i in range(count): cp = start + i glyph, w, h, adv = bitmaps[cp] cx = (i % cols) * cell_w cy = (i // cols) * cell_h if w and h: atlas[cy:cy + h, cx:cx + w] = glyph result.glyphs[cp] = { "w": w, "h": h, "advance": adv, "u0": cx / atlas_w, "v0": cy / atlas_h, "u1": (cx + w) / atlas_w, "v1": (cy + h) / atlas_h, } tex = glGenTextures(1) glBindTexture(GL_TEXTURE_2D, tex) glPixelStorei(GL_UNPACK_ALIGNMENT, 1) glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, atlas_w, atlas_h, 0, GL_RED, GL_UNSIGNED_BYTE, atlas.tobytes()) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) # R8 single channel: swizzle so .r replicates (sampled as coverage). result.texture = tex result.tex_w, result.tex_h = atlas_w, atlas_h return result