Files
linuxcncAllWasm/lib/python/rs274/glcanon_gl.py
2026-08-20 06:17:39 +08:00

3101 lines
128 KiB
Python

# 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