From 6bf82cbd8ec46fb1bd508845fef6e036a960b7dd Mon Sep 17 00:00:00 2001 From: Alexey Presnyakov <309782758+alex-pres@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:17:28 +0400 Subject: [PATCH 1/8] glcanon: rewrite the G-code preview on OpenGL 3.3 core Replaces the fixed-function preview shared by AXIS, the GTK screens and QtVCP with a shader/VBO renderer: a baked trajectory buffer, an offscreen ID-buffer pass for picking, and a glyph atlas for overlay text. The GL matrix stack, display lists, immediate mode and GL_SELECT are gone. --- docs/src/config/python-interface.adoc | 8 + .../getting-started/updating-linuxcnc.adoc | 128 +- lib/python/glnav.py | 169 +- lib/python/hershey.py | 96 +- lib/python/rs274/OpenGLTk.py | 2 + lib/python/rs274/glcanon.py | 1807 +++++-------- lib/python/rs274/glcanon_bake.py | 1463 +++++++++++ lib/python/rs274/glcanon_gl.py | 2271 +++++++++++++++++ lib/python/rs274/glcanon_scene.py | 1935 ++++++++++++++ src/emc/usr_intf/axis/extensions/emcmodule.cc | 38 + src/emc/usr_intf/axis/extensions/togl.c | 83 + src/emc/usr_intf/axis/scripts/axis.py | 35 +- src/emc/usr_intf/gremlin/gremlin.py | 139 +- src/emc/usr_intf/gremlin/qt5_graphics.py | 300 +-- 14 files changed, 6867 insertions(+), 1607 deletions(-) create mode 100644 lib/python/rs274/glcanon_bake.py create mode 100644 lib/python/rs274/glcanon_gl.py create mode 100644 lib/python/rs274/glcanon_scene.py diff --git a/docs/src/config/python-interface.adoc b/docs/src/config/python-interface.adoc index 219a873d244..e2b45bbc395 100644 --- a/docs/src/config/python-interface.adoc +++ b/docs/src/config/python-interface.adoc @@ -935,6 +935,14 @@ Some usage hints can be gleaned from `call()`:: Plot the backplot now. +`points()`:: + Return `(bytes, npts, is_xyuv)`: a copy (taken under the sampler lock) of the + logged point buffer, the number of points, and whether the machine is a foam + (XY/UV) cutter. Each point is a C `logger_point` -- 3 float32 (x, y, z), 4 + uint8 RGBA, 3 float32 (rx, ry, rz or u, v, w), 4 uint8 RGBA -- so the buffer + is `npts * 32` bytes. Added for the OpenGL 3.3 core renderer to upload the + backplot to a GPU buffer; see `rs274.glcanon` for the numpy layout. + `last([int])`:: Return the most recent point on the plot or None diff --git a/docs/src/getting-started/updating-linuxcnc.adoc b/docs/src/getting-started/updating-linuxcnc.adoc index 006f8f233ea..d9f44143dab 100644 --- a/docs/src/getting-started/updating-linuxcnc.adoc +++ b/docs/src/getting-started/updating-linuxcnc.adoc @@ -222,7 +222,133 @@ HOME_SEQUENCE = 0 - Accepted but currently does nothing Touchy: the Touchy MACRO entries should now be placed in a [MACROS] section of the INI rather than in the [TOUCHY] section. This is part of -a process of commonising the INI setting between GUIs. +a process of commonising the INI setting between GUIs. + + +== Preview renderer now requires OpenGL 3.3 core + +The G-code preview shared by AXIS, the GTK screens (Gremlin / gmoccapy / +gscreen / GladeVCP `hal_gremlin` / QtPlasmaC), and QtVCP was rewritten to use +a single modern OpenGL 3.3 *core-profile* renderer (shaders, VBOs, an offscreen +framebuffer for click selection, a glyph-atlas for overlay text). The legacy +fixed-function path (display lists, immediate mode, `GL_SELECT` picking, +`glBitmap` text, line stipple, `GL_LIGHTING`) has been removed. There is no +runtime switch and no in-process fallback. + +*Hardware requirement.* OpenGL 3.3 core is needed. On the supported platform +(Linux with Mesa) this is available on Intel Sandy Bridge (2011) and newer, AMD +r600 and newer, and nouveau. Machines without a capable GPU can use Mesa's +software renderer (llvmpipe), which handles the line-dominated preview +acceptably: + +---- +LIBGL_ALWAYS_SOFTWARE=1 linuxcnc myconfig.ini +---- + +If a core context cannot be created the GUI exits at start-up with a diagnostic +naming the OpenGL 3.3 requirement and suggesting `LIBGL_ALWAYS_SOFTWARE=1`, +rather than starting with a blank or corrupt preview. + +[WARNING] +.BREAKING: out-of-tree screens that inject raw legacy OpenGL +==== +Custom screens that subclass the in-tree preview classes and *override a +drawing internal to emit raw fixed-function OpenGL* (immediate mode, display +lists, `glBitmap`, etc.) will fail against a core context. Compatibility is +preserved only at the *calling surface*: the public methods and attributes of +`rs274.glcanon.GlCanonDraw` / `glnav.GlNavBase` used by the in-tree GUIs keep +their names, signatures, and behaviour (`realize`, `redraw`, +`redraw_perspective`, `redraw_ortho`, `select`, `set_highlight_line`, +`set_canon`, `posstrs`, the `stale_dlist(...)` cache-invalidation entry points, +and the `get_*`/`is_*` callback contract). Move any custom drawing onto those +supported entry points, or draw with your own modern-OpenGL code. +==== + +The immediate-mode drawing helpers used by the old renderer remain available for +compatibility (`linuxcnc.draw_lines`, `linuxcnc.line9`, `linuxcnc.draw_dwells`, +`linuxcnc.positionlogger.call()`); they are unused by the in-tree GUIs, which +bake geometry to VBOs and upload the backplot from `positionlogger.points()`, +but still work for out-of-tree tools under a legacy/compatibility context. + +=== Notes for integrators and driver authors + +* *AXIS / Togl.* The vendored Togl widget (`src/emc/usr_intf/axis/extensions/togl.c`) + gained a boolean `-coreprofile` option (default *false*). When true it creates + the context with `glXChooseFBConfig` + `glXGetVisualFromFBConfig` + + `glXCreateContextAttribsARB` (OpenGL 3.3 core), raising a descriptive Tcl error + on failure. AXIS always enables it. The default (false) path is unchanged, so + `vismach` and any out-of-tree Togl users keep their legacy contexts. + +* *Gremlin (GTK).* GTK3 only hands out core contexts through `GtkGLArea`, so the + gremlin widget still builds its context by hand via GLX, now requesting 3.3 + core with `glXCreateContextAttribsARB`. PyOpenGL cannot resolve that extension + entry point (it comes back as a null function), so gremlin loads `libGL` + directly with `ctypes` and creates *and binds* the context (choose-fbconfig / + make-current / swap) through that one handle to avoid mixing context pointers. + +* *Line width in core profiles.* A forward-compatible core context (Qt requests + one) rejects `glLineWidth(> 1)` with `GL_INVALID_VALUE` even though + `GL_ALIASED_LINE_WIDTH_RANGE` reports a larger maximum. The renderer probes + the accepted width once and caches it, so thick lines (the selection + highlight, dwell markers) degrade to 1 px on such drivers instead of raising; + a non-forward-compatible core context (AXIS's Togl, Gremlin's GLX) keeps the + wider lines. Thick lines via quad expansion are a possible future improvement. + +* *vismach* is unaffected: it keeps its legacy Togl context and immediate-mode + drawing; only its camera consumes the (now GL-free) explicit matrices from + `glnav`. + +=== How the preview is put together + +The drawing itself lives in `lib/python/rs274/glcanon_scene.py`, in four tiers. +Nothing here changes what the preview looks like; it is where to start reading +if you need to fix or extend one part of it. + +* *Parts.* One class per drawing concern - grid, program geometry, extents, + bounding box, offsets, small origin, axes, machine-limits box, tool, live + backplot, DRO overlay, the `user_plot()` hook. A part draws that one thing + and nothing else. Adding a preview element means adding a part and placing it + in the scene's order, not editing an existing part. + +* *The scene.* `PreviewScene` holds the parts in draw order and runs them. + *Visibility is decided by the scene, not the part*: it evaluates each part's + `visible(ctx)` and simply does not call a part whose gate is false, so + `draw()` may assume it is visible and must not open with an + `if not shown: return`. Gates that couple parts belong to the scene too - the + extents-versus-bounding-box either/or, and the translucent compositing + `program_alpha` wraps the program in. + +* *Primitives.* Services several parts share - a line array, a wireframe box, + Hershey vector text, the tool-cone mesh - reached as `ctx.prim`. Letters are a + primitive that axes, extents and offsets all use, not a sibling of "axes". + +* *Passes.* The preview is three sets of depth/blend state, not an arbitrary + order: world geometry, translucent geometry drawn over it at equal depth, and + the screen-space overlay. Each part declares its `pass_`; the scene sets the + state when the pass changes. A part that needs something else for its own + drawing (the tool's constant-alpha blend) restores it afterwards. + +Transforms go through a scoped model-view stack: `with ctx.mv.push():` restores +the previous transform on exit, including when an exception unwinds through it, +so a part cannot leak a transform onto a later one. Offsets, small origin and +axes share one such scope (`RelativeCoordGroup`), because the axes are drawn in +the offset frame the offsets progressively build. + +Parts read a `FrameContext` - an explicit, enumerated list of machine, view and +renderer state, built once per frame by `GlCanonDraw` - rather than the widget +itself. That is what lets them be tested without a window: build a context by +hand, call `part.draw(ctx)`, and assert on the vertices it emitted. See +`tests/glcanon-scene/`. + +Click-to-select is not a part - it draws nothing to the screen. `Picker` renders +the *same* program geometry into an offscreen framebuffer with line numbers +encoded as colour and resolves the nearest hit; `GlCanonDraw.select(x, y)` +delegates to it. It shares one `ProgramGeometry` with the drawing part, so the +pickable geometry and the drawn geometry cannot drift apart. + +Setting `GLCANON_SCENE_DEBUG=1` logs which parts the scene drew and which it +skipped whenever that split changes, and reports depth/blend state a part left +behind. == New HAL components diff --git a/lib/python/glnav.py b/lib/python/glnav.py index 2922c9c92c5..161d57d8a89 100644 --- a/lib/python/glnav.py +++ b/lib/python/glnav.py @@ -2,10 +2,26 @@ import array, itertools import sys +import numpy as np + from OpenGL.GL import * from OpenGL.GLU import * def use_pango_font(font, start, count, will_call_prepost=False): + """Build a glyph-atlas font for the OpenGL 3.3 core overlay renderer. + + Signature preserved for callers (axis.py, gremlin.py, qt5_graphics.py): + returns ``(handle, char_width, line_space)``. The handle is now an opaque + ``rs274.glcanon_gl.GlyphAtlas`` (a texture atlas + overlay shader) instead + of a per-glyph display-list base; consumers pass it through get_font_info() + to the shared overlay pass, which draws text as textured quads. + """ + from rs274 import glcanon_gl + atlas = glcanon_gl.build_atlas(font, start, count) + return atlas, atlas.char_width, atlas.line_space + + +def _legacy_use_pango_font(font, start, count, will_call_prepost=False): import gi gi.require_version('Pango','1.0') gi.require_version('PangoCairo','1.0') @@ -100,15 +116,79 @@ def pango_font_pre(rgba=(1., 1., 0., 1.)): def pango_font_post(): glPopAttrib() +def identity_matrix(): + return np.identity(4, dtype=np.float64) + + +def multiply(*matrices): + """Multiply 4x4 matrices using OpenGL's column-vector convention.""" + result = identity_matrix() + for matrix in matrices: + result = result @ np.asarray(matrix, dtype=np.float64) + return result + + +def translation_matrix(x, y, z): + result = identity_matrix() + result[:3, 3] = (x, y, z) + return result + + +def rotation_matrix(angle, x, y, z): + axis = np.asarray((x, y, z), dtype=np.float64) + length = np.linalg.norm(axis) + if not length: + return identity_matrix() + x, y, z = axis / length + c = math.cos(math.radians(angle)) + s = math.sin(math.radians(angle)) + d = 1.0 - c + return np.array(((x*x*d+c, x*y*d-z*s, x*z*d+y*s, 0.0), + (y*x*d+z*s, y*y*d+c, y*z*d-x*s, 0.0), + (z*x*d-y*s, z*y*d+x*s, z*z*d+c, 0.0), + (0.0, 0.0, 0.0, 1.0)), dtype=np.float64) + + +def perspective_matrix(fovy, aspect, near, far): + f = 1.0 / math.tan(math.radians(fovy) / 2.0) + return np.array(((f / aspect, 0.0, 0.0, 0.0), + (0.0, f, 0.0, 0.0), + (0.0, 0.0, (far + near) / (near - far), 2*far*near / (near - far)), + (0.0, 0.0, -1.0, 0.0)), dtype=np.float64) + + +def ortho_matrix(left, right, bottom, top, near, far): + return np.array(((2.0 / (right - left), 0.0, 0.0, -(right + left) / (right - left)), + (0.0, 2.0 / (top - bottom), 0.0, -(top + bottom) / (top - bottom)), + (0.0, 0.0, -2.0 / (far - near), -(far + near) / (far - near)), + (0.0, 0.0, 0.0, 1.0)), dtype=np.float64) + + +def project(point, modelview, projection, viewport): + clip = np.asarray(projection) @ np.asarray(modelview) @ np.append(point, 1.0) + ndc = clip[:3] / clip[3] + x, y, width, height = viewport + return np.array((x + (ndc[0] + 1.0) * width / 2.0, + y + (ndc[1] + 1.0) * height / 2.0, + (ndc[2] + 1.0) / 2.0)) + + +def unproject(point, modelview, projection, viewport): + x, y, width, height = viewport + ndc = np.array(((point[0] - x) * 2.0 / width - 1.0, + (point[1] - y) * 2.0 / height - 1.0, + point[2] * 2.0 - 1.0, 1.0)) + obj = np.linalg.inv(np.asarray(projection) @ np.asarray(modelview)) @ ndc + return obj[:3] / obj[3] + + def glTranslateScene(w, s, x, y, mousex, mousey): zoom_boost = max(1.0, (5.0 / w.distance) ** 0.6) s *= zoom_boost s = max(0.005, s) - glMatrixMode(GL_MODELVIEW) - mat = glGetDoublev(GL_MODELVIEW_MATRIX) - glLoadIdentity() - glTranslatef(s * (x - mousex), s * (mousey - y), 0.0) - glMultMatrixd(mat) + w.modelview = multiply(translation_matrix(s * (x - mousex), + s * (mousey - y), 0.0), + w.modelview) def glRotateScene(w, s, xcenter, ycenter, zcenter, x, y, mousex, mousey): def snap(a): @@ -123,17 +203,14 @@ def snap(a): lat = min(w.maxlat, max(w.minlat, w.lat + (y - mousey) * .5)) lon = (w.lon + (x - mousex) * .5) % 360 - glMatrixMode(GL_MODELVIEW) - - glTranslatef(xcenter, ycenter, zcenter) - mat = glGetDoublev(GL_MODELVIEW_MATRIX) - - glLoadIdentity() - tx, ty, tz = mat[3][:3] - glTranslatef(tx, ty, tz) - glRotatef(snap(lat), *w.rotation_vectors[0]) - glRotatef(snap(lon), *w.rotation_vectors[1]) - glTranslatef(-xcenter, -ycenter, -zcenter) + # The legacy sequence preserved the translated origin while replacing the + # rotational part of the modelview matrix. Express that sequence directly. + translated = multiply(w.modelview, translation_matrix(xcenter, ycenter, zcenter)) + tx, ty, tz = translated[:3, 3] + w.modelview = multiply(translation_matrix(tx, ty, tz), + rotation_matrix(snap(lat), *w.rotation_vectors[0]), + rotation_matrix(snap(lon), *w.rotation_vectors[1]), + translation_matrix(-xcenter, -ycenter, -zcenter)) w.lat = lat w.lon = lon @@ -194,6 +271,7 @@ def __init__(self): # since last view reset self._totalx = 0.0 self._totaly = 0.0 + self.modelview = identity_matrix() # This should almost certainly be part of some derived class. # But I have put it here for convenience. @@ -211,8 +289,7 @@ def basic_lighting(self): glEnable(GL_LIGHT0) glDepthFunc(GL_LESS) glEnable(GL_DEPTH_TEST) - glMatrixMode(GL_MODELVIEW) - glLoadIdentity() + self.modelview = identity_matrix() def set_background(self, r, g, b): @@ -269,8 +346,7 @@ def set_eyepoint_from_extents(self, e1, e2): def reset(self): """Reset rotation matrix for this widget.""" - glMatrixMode(GL_MODELVIEW) - glLoadIdentity() + self.modelview = identity_matrix() self._redraw() # zero the translations - we will be recentering self._totalx = 0.0 @@ -305,7 +381,6 @@ def scale(self, x, y): def rotate(self, x, y): """Perform rotation of scene.""" - self.activate() self.perspective = True glRotateScene(self, 0.5, self.xcenter, self.ycenter, self.zcenter, x, y, self.xmouse, self.ymouse) if self.is_lathe(): @@ -317,13 +392,14 @@ def rotate(self, x, y): def translate(self, x, y): """Perform translation of scene.""" - self.activate() - # Scale mouse translations to object viewplane so object tracks with mouse - win_height = max( 1,self.winfo_height() ) + win_width = max(1, self.winfo_width()) + win_height = max(1, self.winfo_height()) obj_c = ( self.xcenter, self.ycenter, self.zcenter ) - win = gluProject( obj_c[0], obj_c[1], obj_c[2]) - obj = gluUnProject( win[0], win[1] + 0.5 * win_height, win[2]) + projection = self.get_projection_matrix(win_width, win_height) + win = project(obj_c, self.modelview, projection, (0, 0, win_width, win_height)) + obj = unproject((win[0], win[1] + 0.5 * win_height, win[2]), + self.modelview, projection, (0, 0, win_width, win_height)) dist = math.sqrt( v3distsq( obj, obj_c ) ) scale = abs( dist / ( 0.5 * win_height ) ) @@ -393,12 +469,27 @@ def rotateOrTranslate(self, x, y): def get_total_translation(self): return self._totalx, self._totaly + def get_projection_matrix(self, width, height): + """Return the complete legacy projection, including its eye translation.""" + width = max(1, width) + height = max(1, height) + if self.perspective: + return multiply(perspective_matrix(self.fovy, float(width) / height, + self.near, self.far + self.distance), + translation_matrix(0.0, 0.0, -self.distance)) + k = abs(self.distance or 1.0) ** .55555 + return multiply(ortho_matrix(-k, k, -k * height / width, k * height / width, + -1000.0, 1000.0), + translation_matrix(0.0, 0.0, -1.0)) + + def get_modelview_matrix(self): + return self.modelview.copy() + def set_view_x(self): self.reset() - glRotatef(-90, 0, 1, 0) - glRotatef(-90, 1, 0, 0) + self.modelview = multiply(self.modelview, rotation_matrix(-90, 0, 1, 0), rotation_matrix(-90, 1, 0, 0)) mid, size = self.extents_info() - glTranslatef(-mid[0], -mid[1], -mid[2]) + self.modelview = multiply(self.modelview, translation_matrix(-mid[0], -mid[1], -mid[2])) self.set_eyepoint_from_extents(size[1], size[2]) self.perspective = False self.lat = -90 @@ -407,11 +498,11 @@ def set_view_x(self): def set_view_y(self): self.reset() - glRotatef(-90, 1, 0, 0) + self.modelview = multiply(self.modelview, rotation_matrix(-90, 1, 0, 0)) if self.is_lathe(): - glRotatef(90, 0, 1, 0) + self.modelview = multiply(self.modelview, rotation_matrix(90, 0, 1, 0)) mid, size = self.extents_info() - glTranslatef(-mid[0], -mid[1], -mid[2]) + self.modelview = multiply(self.modelview, translation_matrix(-mid[0], -mid[1], -mid[2])) self.set_eyepoint_from_extents(size[0], size[2]) self.perspective = False self.lat = -90 @@ -421,10 +512,9 @@ def set_view_y(self): # lathe backtool display def set_view_y2(self): self.reset() - glRotatef(90, 1, 0, 0) - glRotatef(90, 0, 1, 0) + self.modelview = multiply(self.modelview, rotation_matrix(90, 1, 0, 0), rotation_matrix(90, 0, 1, 0)) mid, size = self.extents_info() - glTranslatef(-mid[0], -mid[1], -mid[2]) + self.modelview = multiply(self.modelview, translation_matrix(-mid[0], -mid[1], -mid[2])) self.set_eyepoint_from_extents(size[0], size[2]) self.perspective = False self.lat = -90 @@ -434,7 +524,7 @@ def set_view_y2(self): def set_view_z(self): self.reset() mid, size = self.extents_info() - glTranslatef(-mid[0], -mid[1], -mid[2]) + self.modelview = multiply(self.modelview, translation_matrix(-mid[0], -mid[1], -mid[2])) self.set_eyepoint_from_extents(size[0], size[1]) self.perspective = False self.lat = self.lon = 0 @@ -442,9 +532,9 @@ def set_view_z(self): def set_view_z2(self): self.reset() - glRotatef(-90, 0, 0, 1) + self.modelview = multiply(self.modelview, rotation_matrix(-90, 0, 0, 1)) mid, size = self.extents_info() - glTranslatef(-mid[0], -mid[1], -mid[2]) + self.modelview = multiply(self.modelview, translation_matrix(-mid[0], -mid[1], -mid[2])) self.set_eyepoint_from_extents(size[1], size[0]) self.perspective = False self.lat = 0 @@ -455,7 +545,7 @@ def set_view_p(self): self.reset() self.perspective = True mid, size = self.extents_info() - glTranslatef(-mid[0], -mid[1], -mid[2]) + self.modelview = multiply(self.modelview, translation_matrix(-mid[0], -mid[1], -mid[2])) size = (size[0] ** 2 + size[1] ** 2 + size[2] ** 2) ** .5 if size > 1e99: size = 5. # in case there are no moves in the preview w = self.winfo_width() @@ -472,4 +562,3 @@ def set_view_p(self): self._redraw() # vim:ts=8:sts=4:sw=4:et: - diff --git a/lib/python/hershey.py b/lib/python/hershey.py index 00c6ec0570a..9cae15e9149 100644 --- a/lib/python/hershey.py +++ b/lib/python/hershey.py @@ -104,60 +104,60 @@ def __init__(self): [[(60, 20), (60, 400), (100, 440), (160, 440), (200, 400), (240, 440), (300, 440), (340, 400), (340, 20)], [(200, 400), (200, 300)]], - ) - self.lists = glGenLists(len(self.hershey)) + ) + # The preview renderer draws Hershey glyphs through the line shader via + # string_polylines(); the legacy per-glyph GL display lists (glGenLists/ + # glNewList) and the plot_digit/plot_string draw helpers that used them + # are gone (they are removed from OpenGL core profiles). - for i in range(len(self.hershey)): - digit = self.hershey[i] - glNewList(self.lists + i, GL_COMPILE) - for stroke in digit: - glBegin(GL_LINE_STRIP) - for point in stroke: - glVertex3f(point[0], 440-point[1], 0) - glEnd() - glEndList() + def string_polylines(self, s, frac=0.0, flip_y=False, flip_z=False, + bbox=False): + """Return a string's strokes as polylines in the local text frame. + + GL-free equivalent of ``plot_string``'s geometry: glyphs are laid out in + the post-(1/440)-scale frame (each ~1 unit tall) with the same advance + widths and ``frac`` offset, and the two readability flips applied. The + caller decides ``flip_y``/``flip_z`` from ``modelview[2][2] < -0.001`` / + ``modelview[1][1] < -0.001`` (the diagonal terms plot_string tests), and + supplies the outer positioning transform itself. Returns a list of + polylines, each a list of ``(x, y)`` points. + """ + frac_final = frac + if flip_y: + frac_final = 1.0 - frac_final + if flip_z: + frac_final = 1.0 - frac_final + slen = self.string_len(s) - def plot_digit(self, n): - glPushMatrix() - glScalef(1/440.0, 1/440.0, 1/440.0) - glCallList(self.lists + n) - glPopMatrix() + def place(x440, y440): + gx = x440 / 440.0 - slen * frac_final + gy = y440 / 440.0 + if flip_z: + gx, gy = -gx, 1.0 - gy + if flip_y: + gx = -gx + return (gx, gy) - def plot_string(self, s, frac=0, bbox=0): - glPushMatrix() - mat = glGetDoublev(GL_MODELVIEW_MATRIX) - if mat[2][2] < -.001: - glTranslatef(0, .5, 0) - glRotatef(180, 0, 1, 0) - glTranslatef(0, -.5, 0) - frac = 1 - frac - mat = glGetDoublev(GL_MODELVIEW_MATRIX) - if mat[1][1] < -.001: - glTranslatef(0, .5, 0) - glRotatef(180, 0, 0, 1) - glTranslatef(0, -.5, 0) - frac = 1 - frac - if frac: - len = self.string_len(s) - glTranslatef(-len*frac, 0, 0) - glScalef(1/440.0, 1/440.0, 1/440.0) + polylines = [] if bbox: - glBegin(GL_LINE_STRIP) - glVertex3f(-140, -140, 0) - glVertex3f(self.string_len(s)*440.0 + 140, -140, 0) - glVertex3f(self.string_len(s)*440.0 + 140, 580.0, 0) - glVertex3f(-140, 580.0, 0) - glVertex3f(-140, -140, 0) - glEnd() + # Same rectangle plot_string draws around the string (in 440-space). + right = slen * 440.0 + 140.0 + polylines.append([place(-140.0, -140.0), place(right, -140.0), + place(right, 580.0), place(-140.0, 580.0), + place(-140.0, -140.0)]) + advance = 0.0 for c in s: - glCallList(self.lists + translate[c]) + digit = self.hershey[translate[c]] + for stroke in digit: + polylines.append([place(x + advance, 440.0 - y) + for x, y in stroke]) if c == '1': - glTranslatef(260, 0, 0) + advance += 260.0 elif c == '.': - glTranslatef(180, 0, 0) + advance += 180.0 else: - glTranslatef(400, 0, 0) - glPopMatrix() + advance += 400.0 + return polylines def string_len(self, s): l = 0.0 @@ -171,7 +171,3 @@ def string_len(self, s): return l/440.0 - def center_string(self, s): - len = self.string_len(s) - glTranslatef(-len/2, -.5, 0) - diff --git a/lib/python/rs274/OpenGLTk.py b/lib/python/rs274/OpenGLTk.py index d768ec29eee..e0f1a9fceaf 100755 --- a/lib/python/rs274/OpenGLTk.py +++ b/lib/python/rs274/OpenGLTk.py @@ -346,6 +346,8 @@ def tkRedraw(self, *dummy): self.xcenter, self.ycenter, self.zcenter, 0., 1., 0.) glMatrixMode(GL_MODELVIEW) + + glLoadMatrixd(self.get_modelview_matrix().T) # Call objects redraw method. self.redraw() diff --git a/lib/python/rs274/glcanon.py b/lib/python/rs274/glcanon.py index 9cd10472b01..9a017deb528 100644 --- a/lib/python/rs274/glcanon.py +++ b/lib/python/rs274/glcanon.py @@ -16,99 +16,98 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from rs274 import Translated, ArcsToSegmentsMixin +from rs274 import glcanon_gl, glcanon_bake, glcanon_scene +# Bound locally: the canon tags every move with one of these, so the lookup is +# on the parse hot path. +from rs274.glcanon_bake import CAT_TRAVERSE, CAT_FEED, CAT_ARC + from OpenGL.GL import * from OpenGL.GLU import * import math import hershey import linuxcnc -import array import gcode +import numpy as np import os -import re from functools import reduce -def minmax(*args): - return min(*args), max(*args) - -allhomedicon = array.array('B', - [0x00, 0x00, - 0x00, 0x00, - 0x00, 0x00, - 0x08, 0x20, - 0x08, 0x20, - 0x08, 0x20, - 0x08, 0x20, - 0x08, 0x20, - 0x0f, 0xe0, - 0x08, 0x20, - 0x08, 0x20, - 0x08, 0x20, - 0x08, 0x20, - 0x00, 0x00, - 0x00, 0x00, - 0x00, 0x00]) - -somelimiticon = array.array('B', - [0x00, 0x00, - 0x00, 0x00, - 0x00, 0x00, - 0x0f, 0xc0, - 0x08, 0x00, - 0x08, 0x00, - 0x08, 0x00, - 0x08, 0x00, - 0x08, 0x00, - 0x08, 0x00, - 0x08, 0x00, - 0x08, 0x00, - 0x08, 0x00, - 0x00, 0x00, - 0x00, 0x00, - 0x00, 0x00]) - -homeicon = array.array('B', - [0x2, 0x00, 0x02, 0x00, 0x02, 0x00, 0x0f, 0x80, - 0x1e, 0x40, 0x3e, 0x20, 0x3e, 0x20, 0x3e, 0x20, - 0xff, 0xf8, 0x23, 0xe0, 0x23, 0xe0, 0x23, 0xe0, - 0x13, 0xc0, 0x0f, 0x80, 0x02, 0x00, 0x02, 0x00]) - -limiticon = array.array('B', - [ 0, 0, 128, 0, 134, 0, 140, 0, 152, 0, 176, 0, 255, 255, - 255, 255, 176, 0, 152, 0, 140, 0, 134, 0, 128, 0, 0, 0, - 0, 0, 0, 0]) - -# Axis Views -X = 0 -Y = 1 -Z = 2 -A = 3 -B = 4 -C = 5 -U = 6 -V = 7 -W = 8 -R = 9 - -# View ports coordinates -VX = 0 -VY = 1 -VZ = 2 -VP = 3 +# Axis views, viewport coordinates and the DRO home/limit icons now live with +# the scene that draws from them; they are re-exported here because they have +# long been part of this module's surface. +from rs274.glcanon_scene import (X, Y, Z, A, B, C, U, V, W, R, # noqa: F401 + VX, VY, VZ, VP, minmax, + allhomedicon, somelimiticon, homeicon, + limiticon) + +# Moves the canon lets pile up before filling them into the program record. +# Checked once per source line, not once per move, so it costs nothing on the +# move path; it bounds the fill's peak working set rather than the batch size +# exactly, which is all it is for. +FILL_BATCH = 4096 + + +def _removed_attribute(name, replacement): + """A read-only property that raises, naming what replaced it. + + ``traverse``/``feed``/``arcfeed``/``moves``/``move_cats`` and + ``preview_zero_rxy`` are undocumented but twenty years old, and a + third-party GUI or a user's canon subclass may still read one. Silently + returning nothing - or a bare ``AttributeError`` - leaves such code + guessing; this is a signpost, not a compatibility shim, and it must not + appear to work. + """ + def getter(self): + raise AttributeError( + "GLCanon.%s was removed (see retire-canon-move-lists); %s" + % (name, replacement)) + return property(getter) + class GLCanon(Translated, ArcsToSegmentsMixin): lineno = -1 + + # See _removed_attribute: these were the per-move category lists, + # emission-order list and un-rotated preview copy. The program record + # (self.program_geometry) replaces all of them. + traverse = _removed_attribute( + "traverse", "read program_geometry (positions()/lines/kinds) or g0_length") + feed = _removed_attribute( + "feed", "read program_geometry (positions()/lines/kinds) or g1_length/run_time()") + arcfeed = _removed_attribute( + "arcfeed", "read program_geometry (positions()/lines/kinds) or g1_length/run_time()") + moves = _removed_attribute( + "moves", "read program_geometry - it is the program record, in emission order") + move_cats = _removed_attribute( + "move_cats", "read program_geometry.kinds") + preview_zero_rxy = _removed_attribute( + "preview_zero_rxy", + "read program_geometry.extents_zero_rxy / extents_notool_zero_rxy, " + "accumulated during the fill") + def __init__(self, colors, geometry, is_foam=0, foam_w=1.5, foam_z=0.0): - # traverse list of tuples - [(line number, (start position), (end position), (tlo x, tlo y, tlo z))] - self.traverse = [] - # feed list of tuples - [(line number, (start position), (end position), feedrate, (tlo x, tlo y, tlo z))] - self.feed = [] - # arcfeed list of tuples - [(line number, (start position), (end position), feedrate, (tlo x, tlo y, tlo z))] - self.arcfeed = [] # dwell list - [line number, color, pos x, pos y, pos z, plane] self.dwells = [] self.tool_list = [] - # preview list - combines the unrotated points of the lists: self.traverse, self.feed, self.arcfeed - self.preview_zero_rxy = [] + # The program record. Constructed here rather than lazily so that + # "the canon always has one" is true of a canon nothing ever parsed + # into, and readable the moment gcode.parse returns - which is when + # load_preview wants the extents, long before any GL context exists. + # Named program_geometry, not geometry: that name is already this + # class's GEOMETRY *string*, which the fill reads. + self._program_geometry = glcanon_bake.ProgramGeometry( + geometry=geometry, is_foam=bool(is_foam)) + # Moves recorded since the last flush: a reusable float64 chunk + # (lineno, p1, p2, feedrate, offset per row - see STAGING_ROW_WIDTH) + # that rotate_and_translate's result is written straight into, plus a + # parallel byte per move naming its category. Neither is retained + # once flushed. Cost is bounded by FILL_BATCH, never by program + # length - and normally by one allocation for the file, since the + # array is reused in place and only grows if a single source line + # emits more moves than fit before the next flush check. + self._staging = np.empty( + (FILL_BATCH, glcanon_bake.STAGING_ROW_WIDTH), dtype=np.float64) + self._staged = 0 + self._pending_kinds = bytearray() self.choice = None self.feedrate = 1 self.lo = (0,) * 9 @@ -195,37 +194,128 @@ def check_abort(self): pass def next_line(self, st): self.state = st self.lineno = self.state.sequence_number + if self._staged >= FILL_BATCH: + self._flush_moves() - def draw_lines(self, lines, for_selection, j=0, geometry=None): - return linuxcnc.draw_lines(geometry or self.geometry, lines, for_selection) + # -- the program record ------------------------------------------------ - def colored_lines(self, color, lines, for_selection, j=0): - if self.is_foam: - if not for_selection: - self.color_with_alpha(color + "_xy") - glPushMatrix() - glTranslatef(0, 0, self.foam_z) - self.draw_lines(lines, for_selection, 2*j, 'XY') - glPopMatrix() - if not for_selection: - self.color_with_alpha(color + "_uv") - glPushMatrix() - glTranslatef(0, 0, self.foam_w) - self.draw_lines(lines, for_selection, 2*j+len(lines), 'UV') - glPopMatrix() - else: - if not for_selection: - self.color_with_alpha(color) - self.draw_lines(lines, for_selection, j) + @property + def program_geometry(self): + """The program record, with every move reported so far filled in. - def draw_dwells(self, dwells, alpha, for_selection, j0=0): - return linuxcnc.draw_dwells(self.geometry, dwells, alpha, for_selection, self.is_lathe()) + A property rather than a plain attribute so that reading it is always + reading a complete record: moves accumulate between flushes, and a + reader that took the object during the parse would otherwise see a + prefix of the program and no way to know it. + """ + self._flush_moves() + return self._program_geometry + + def configure_program_geometry(self, geometry, ro, is_foam): + """Choose the transform the fill will apply, and clear what it filled. + + Called by the widget when the canon is set, i.e. just before the + parse. The points are converted once, on the way in, so this is not + something that can be changed afterwards - which is why it discards + whatever was already filled rather than leaving a half-transformed + array behind. + + Deliberately does NOT write back to ``self.geometry`` or + ``self.is_foam``. Those are the canon's own, set by whoever + constructed it, and gremlin's does not set ``is_foam`` at all while + its widget may report foam - so adopting the widget's answer here + would quietly change which programs get the foam Z override in + calc_extents. The drawn planes follow the widget, exactly as the + pre-change bake did; the extents rule follows the canon, exactly as + it did. + """ + self._staged = 0 + self._pending_kinds = bytearray() + self._program_geometry.configure(geometry=geometry, ro=ro, + is_foam=is_foam) + + def _reserve_staging(self, extra): + """Grow the staging chunk to hold ``extra`` more rows, by doubling. + + Normally a no-op: the chunk is reused across flushes at its initial + ``FILL_BATCH`` capacity. It only grows for the rare source line that + emits more moves than that in one callback - a large arc's segments, + say - since the flush check in ``next_line`` runs once per source + line, not once per move. + """ + need = self._staged + extra + cap = len(self._staging) + if need <= cap: + return + new_cap = max(FILL_BATCH, cap * 2) + while new_cap < need: + new_cap *= 2 + grown = np.empty((new_cap, glcanon_bake.STAGING_ROW_WIDTH), + dtype=np.float64) + grown[:self._staged] = self._staging[:self._staged] + self._staging = grown + + def _stage_move(self, lineno, p1, p2, feedrate, offset, kind): + """Write one move straight into the staging chunk. No tuple, no list + append beyond the one-byte kind.""" + self._reserve_staging(1) + row = self._staging[self._staged] + row[glcanon_bake.STAGE_LINE] = lineno + row[glcanon_bake.STAGE_P1] = p1 + row[glcanon_bake.STAGE_P2] = p2 + row[glcanon_bake.STAGE_FEEDRATE] = feedrate + row[glcanon_bake.STAGE_OFFSET] = offset + self._pending_kinds.append(kind) + self._staged += 1 + + def _flush_moves(self): + """Fill everything recorded since the last flush. + + The rotation and g5x origin are passed per batch because they are per + batch: they are what the rotation-removed extents are accumulated + with, and a program that rotates mid-file gets each move's own. That + is why set_xy_rotation and set_g5x_offset flush before changing them. + """ + n = self._staged + if n == 0: + return + chunk = self._staging[:n] + kinds = self._pending_kinds + # Cleared before the call, not after: add_moves_raw is where a + # malformed move would raise, and a non-empty pending count would + # re-fill the same rows into the array on the next flush. The chunk + # itself is not reallocated - add_moves_raw reads it before this + # method returns, and self._staged = 0 alone is what lets the next + # _stage_move start overwriting it. + self._staged = 0 + self._pending_kinds = bytearray() + self._program_geometry.add_moves_raw( + chunk, kinds, self.rotation_xy, + (self.g5x_offset_x, self.g5x_offset_y)) + + def set_xy_rotation(self, theta): + self._flush_moves() + Translated.set_xy_rotation(self, theta) + + def set_g5x_offset(self, *args, **kw): + self._flush_moves() + Translated.set_g5x_offset(self, *args, **kw) def calc_extents(self): - # in the event of a "blank" gcode file (M2 only for example) this sets each of the extents to [0,0,0] + # A delegation onto the values accumulated during the fill, plus the + # two rules that have always lived here and are not properties of the + # move data at all: the blank-program case and the foam Z override. + # + # gcode.calc_extents (C) stays in emcmodule.cc as public module API, + # but nothing here calls it any more - see + # tests/gcode-bake/test_extents_oracle.py, which now pins the + # accumulated values against fixtures recorded from that oracle + # rather than calling it live. + geometry = self.program_geometry + # In the event of a "blank" gcode file (M2 only for example) this sets each of the extents to [0,0,0] # to prevent passing the very large [9e99,9e99,9e99] values and populating the gcode properties with # unusably large values. Some screens use the extents information to set the view distance so 0 values are preferred. - if not self.arcfeed and not self.feed and not self.traverse: + if geometry.is_empty: self.min_extents = \ self.max_extents = \ self.min_extents_notool = \ @@ -235,9 +325,20 @@ def calc_extents(self): self.min_extents_notool_zero_rxy = \ self.max_extents_notool_zero_rxy = [0,0,0] return - self.min_extents, self.max_extents, self.min_extents_notool, self.max_extents_notool = gcode.calc_extents(self.arcfeed, self.feed, self.traverse) - self.unrotate_preview() - self.min_extents_zero_rxy, self.max_extents_zero_rxy, self.min_extents_notool_zero_rxy, self.max_extents_notool_zero_rxy = gcode.calc_extents(self.preview_zero_rxy) + # Plain floats, not numpy scalars: these eight are read outside + # rs274 - by the AXIS, gremlin and QtVCP properties dialogs - and have + # always been lists of Python floats, which is what the C returned. + def pair(extents): + return [[float(v) for v in vector] for vector in extents] + + (self.min_extents, self.max_extents) = pair(geometry.extents) + (self.min_extents_notool, + self.max_extents_notool) = pair(geometry.extents_notool) + (self.min_extents_zero_rxy, + self.max_extents_zero_rxy) = pair(geometry.extents_zero_rxy) + (self.min_extents_notool_zero_rxy, + self.max_extents_notool_zero_rxy) = pair( + geometry.extents_notool_zero_rxy) if self.is_foam: min_z = min(self.foam_z, self.foam_w) max_z = max(self.foam_z, self.foam_w) @@ -248,36 +349,37 @@ def calc_extents(self): self.max_extents_notool = \ self.max_extents_notool[0], self.max_extents_notool[1], max_z - # unrotates the current preview points defined by self.feed, self.arcfeed, self.traverse - # by the current rotation_xy amount and populates self.preview_zero_rxy. Because this is - # only used to calculate the extents and not to draw to the screen, this can all be contained in the same list. - def unrotate_preview(self): - angle = math.radians(-self.rotation_xy) - cos = math.cos(angle) - sin = math.sin(angle) - g5x_x = self.g5x_offset_x - g5x_y = self.g5x_offset_y - for movelist in self.feed, self.arcfeed: - for linenum, start, end, feed, tooloffset in movelist: - tsx = start[0] - g5x_x - tsy = start[1] - g5x_y - tex = end[0] - g5x_x - tey = end[1] - g5x_y - rsx = (tsx * cos) - (tsy * sin) + g5x_x - rsy = (tsx * sin) + (tsy * cos) + g5x_y - rex = (tex * cos) - (tey * sin) + g5x_x - rey = (tex * sin) + (tey * cos) + g5x_y - self.preview_zero_rxy.append((linenum, (rsx, rsy) + start[2:], (rex, rey) + end[2:], feed, tooloffset)) - for linenum, start, end, tooloffset in self.traverse: - tsx = start[0] - g5x_x - tsy = start[1] - g5x_y - tex = end[0] - g5x_x - tey = end[1] - g5x_y - rsx = (tsx * cos) - (tsy * sin) + g5x_x - rsy = (tsx * sin) + (tsy * cos) + g5x_y - rex = (tex * cos) - (tey * sin) + g5x_x - rey = (tex * sin) + (tey * cos) + g5x_y - self.preview_zero_rxy.append((linenum, (rsx, rsy) + start[2:], (rex, rey) + end[2:], tooloffset)) + @property + def g0_length(self): + """Total rapid (traverse) path length, accumulated during the fill. + + Replaces ``sum(dist(l[1][:3], l[2][:3]) for l in canon.traverse)``, + read by the gremlin/AXIS/qt5_graphics properties dialogs. + """ + return self.program_geometry.rapid_length + + @property + def g1_length(self): + """Total cutting (feed + arc) path length, accumulated during the fill. + + Replaces the equivalent summation over ``canon.feed``/``canon.arcfeed``. + """ + return self.program_geometry.cutting_length + + def run_time(self, max_feed_rate): + """Cutting + rapid time at ``max_feed_rate``, plus ``self.dwell_time``. + + ``max_feed_rate`` is the machine's ``max_speed``, known only to the + GUI that built this canon - not to the parse - which is why this is a + method rather than a plain attribute; see the design's discussion of + why ``min(max_feed_rate, feed)`` cannot be pre-summed into one scalar. + Replaces the ``gt`` summation the properties dialogs used to run over + ``traverse``/``feed``/``arcfeed``. + """ + geometry = self.program_geometry + return (geometry.cutting_time(max_feed_rate) + + geometry.rapid_length / max_feed_rate + + self.dwell_time) def tool_offset(self, xo, yo, zo, ao, bo, co, uo, vo, wo): self.first_move = True @@ -304,12 +406,20 @@ def change_tool(self, arg): self.tool_list.append(arg) except Exception as e: print(e) + # Flush first: the record vertex has to land between the move before + # the change and the move after it, and the moves before it are still + # sitting in the pending buffer. The jump that follows is not recorded + # here - first_move means the next move starts wherever the tool + # ended up, so the fill sees the discontinuity itself. + self._flush_moves() + self._program_geometry.mark_toolchange(self.lineno, self.lo, arg) def straight_traverse(self, x,y,z, a,b,c, u,v,w): if self.suppress > 0: return l = self.rotate_and_translate(x,y,z,a,b,c,u,v,w) if not self.first_move: - self.traverse.append((self.lineno, self.lo, l, (self.xo, self.yo, self.zo))) + self._stage_move(self.lineno, self.lo, l, 0.0, + (self.xo, self.yo, self.zo), CAT_TRAVERSE) self.lo = l def rigid_tap(self, x, y, z): @@ -318,9 +428,11 @@ def rigid_tap(self, x, y, z): l = self.rotate_and_translate(x,y,z,0,0,0,0,0,0)[:3] l += (self.lo[3], self.lo[4], self.lo[5], self.lo[6], self.lo[7], self.lo[8]) - self.feed.append((self.lineno, self.lo, l, self.feedrate, (self.xo, self.yo, self.zo))) + offset = (self.xo, self.yo, self.zo) # self.dwells.append((self.lineno, self.colors['dwell'], x + self.offset_x, y + self.offset_y, z + self.offset_z, 0)) - self.feed.append((self.lineno, l, self.lo, self.feedrate, (self.xo, self.yo, self.zo))) + self._reserve_staging(2) + self._stage_move(self.lineno, self.lo, l, self.feedrate, offset, CAT_FEED) + self._stage_move(self.lineno, l, self.lo, self.feedrate, offset, CAT_FEED) def arc_feed(self, *args): if self.suppress > 0: return @@ -337,9 +449,12 @@ def straight_arcsegments(self, segs): lineno = self.lineno feedrate = self.feedrate to = (self.xo, self.yo, self.zo) - append = self.arcfeed.append + # Reserved once for the whole arc, not once per segment: an arc can + # subdivide into tens of segments in one call, all before next_line + # next checks the flush threshold. + self._reserve_staging(len(segs)) for l in segs: - append((lineno, lo, l, feedrate, to)) + self._stage_move(lineno, lo, l, feedrate, to, CAT_ARC) lo = l self.lo = lo @@ -347,57 +462,77 @@ def straight_feed(self, x,y,z, a,b,c, u,v,w): if self.suppress > 0: return self.first_move = False l = self.rotate_and_translate(x,y,z,a,b,c,u,v,w) - self.feed.append((self.lineno, self.lo, l, self.feedrate, (self.xo, self.yo, self.zo))) + self._stage_move(self.lineno, self.lo, l, self.feedrate, + (self.xo, self.yo, self.zo), CAT_FEED) self.lo = l def straight_probe(self, x,y,z, a,b,c, u,v,w): if self.suppress > 0: return self.first_move = False l = self.rotate_and_translate(x,y,z,a,b,c,u,v,w) - self.feed.append((self.lineno, self.lo, l, self.feedrate, (self.xo, self.yo, self.zo))) + self._stage_move(self.lineno, self.lo, l, self.feedrate, + (self.xo, self.yo, self.zo), CAT_FEED) self.lo = l def user_defined_function(self, i, p, q): if self.suppress > 0: return color = self.colors['m1xx'] - self.dwells.append((self.lineno, color, self.lo[0], self.lo[1], self.lo[2], int(self.state.plane/10-17))) + self._record_dwell(color) def dwell(self, arg): if self.suppress > 0: return self.dwell_time += arg color = self.colors['dwell'] - self.dwells.append((self.lineno, color, self.lo[0], self.lo[1], self.lo[2], int(self.state.plane/10-17))) + self._record_dwell(color) + + def _record_dwell(self, color): + """Append a dwell to ``self.dwells`` and to the program record. + + ``self.dwells`` keeps raw machine coordinates, because that is what it + has always held; it is bounded by event count, not move count, so it + is kept (unlike the per-move category lists). The record takes the + 9-DOF point and transforms it, which is the marker position fix: the + pre-change marker bake was handed the GEOMETRY string and the + rotation offsets and applied neither. + """ + plane = int(self.state.plane/10-17) + self.dwells.append((self.lineno, color, self.lo[0], self.lo[1], + self.lo[2], plane)) + self._flush_moves() + self._program_geometry.mark_dwell(self.lineno, self.lo, color, plane) def highlight(self, lineno, geometry): - glLineWidth(3) - glColor3f(*self.colors['selected']) - glBegin(GL_LINES) - coords = [] - for line in self.traverse: - if line[0] != lineno: continue - linuxcnc.line9(geometry, line[1], line[2]) - coords.append(line[1][:3]) - coords.append(line[2][:3]) - for line in self.arcfeed: - if line[0] != lineno: continue - linuxcnc.line9(geometry, line[1], line[2]) - coords.append(line[1][:3]) - coords.append(line[2][:3]) - for line in self.feed: - if line[0] != lineno: continue - linuxcnc.line9(geometry, line[1], line[2]) - coords.append(line[1][:3]) - coords.append(line[2][:3]) - glEnd() - for line in self.dwells: - if line[0] != lineno: continue - self.draw_dwells([(line[0], self.colors['selected']) + line[2:]], 2, 0) - coords.append(line[2:5]) - glLineWidth(1) - if coords: - x = reduce(lambda _x, _y: _x+_y, [p[0] for p in coords]) / len(coords) - y = reduce(lambda _x, _y: _x+_y, [p[1] for p in coords]) / len(coords) - z = reduce(lambda _x, _y: _x+_y, [p[2] for p in coords]) / len(coords) + # Return the centroid of the highlighted line's segments; the view + # recentres on it. The highlight geometry itself is drawn by + # glcanon_scene.HighlightPart, so no GL is emitted here. In + # particular we must NOT call linuxcnc.line9() (as the legacy path did): + # it emits immediate-mode glVertex, which is invalid in the 3.3 core + # profile and leaves a GL_INVALID_OPERATION pending that later surfaces + # on an unrelated glViewport. + # + # A mask on the array's line column, covering drawn segments and dwell + # records in one expression, replacing four full-program Python loops. + # The former loops collected BOTH endpoints of every matching segment, + # so an interior point shared by two same-line segments counted twice; + # reproduced here by weighting each vertex by the number of incident + # same-line drawn segments, plus one for a dwell record of its own. + geom = self.program_geometry + n = len(geom) + weight = np.zeros(n, dtype=np.float64) + if n > 1: + lines = geom.lines + kinds = geom.kinds + seg_match = ((lines[1:] == lineno) + & (kinds[1:] <= glcanon_bake.LAST_DRAWN_KIND)) + weight[:-1] += seg_match + weight[1:] += seg_match + weight += (lines == lineno) & (kinds == glcanon_bake.KIND_DWELL) + total = weight.sum() + if total > 0: + pos = geom.positions() + x = float((pos[:, 0] * weight).sum() / total) + y = float((pos[:, 1] * weight).sum() / total) + z = float((pos[:, 2] * weight).sum() / total) else: x = (self.min_extents[X] + self.max_extents[X])/2 y = (self.min_extents[Y] + self.max_extents[Y])/2 @@ -409,18 +544,6 @@ def color_with_alpha(self, colorname): def color(self, colorname): glColor3f(*self.colors[colorname]) - def draw(self, for_selection=0, no_traverse=True): - if not no_traverse: - self.colored_lines('traverse', self.traverse, for_selection) - else: - self.colored_lines('straight_feed', self.feed, for_selection, len(self.traverse)) - - self.colored_lines('arc_feed', self.arcfeed, for_selection, len(self.traverse) + len(self.feed)) - - glLineWidth(2) - self.draw_dwells(self.dwells, int(self.colors.get('dwell_alpha', 1/3.)), for_selection, len(self.traverse) + len(self.feed) + len(self.arcfeed)) - glLineWidth(1) - def with_context(f): def inner(self, *args, **kw): self.activate() @@ -501,7 +624,18 @@ def __init__(self, s=None, lp=None, g=None): self.stat = s self.lp = lp self.canon = g - self._dlists = {} + self._renderer = None # rs274.glcanon_gl.GlCanonRenderer (lazy) + # Explicit numpy model-view stack replacing the legacy GL matrix stack; + # (re)seeded to the camera modelview at frame start. self._projection is + # the frame's projection (glnav folds the eye translation into it). + self.mv = glcanon_scene.MatrixStack() + # Shared drawing services the scene parts call through ctx.prim, and + # the ordered scene redraw() runs. + self.prim = glcanon_scene.Primitives() + self.scene = self.build_scene() + # Click-to-select shares the program's baked geometry with the scene, + # so what is pickable is exactly what is drawn. + self.picker = glcanon_scene.Picker(self.scene.program.resource) self.select_buffer_size = 100 self.cached_tool = -1 self.initialised = 0 @@ -510,7 +644,6 @@ def __init__(self, s=None, lp=None, g=None): self.trajcoordinates = "unknown" self.dro_in = "% 9.4f" self.dro_mm = "% 9.3f" - self.show_overlay = False self.enable_dro = True self.cone_basesize = .5 self.show_small_origin = True @@ -574,6 +707,16 @@ def __init__(self, s=None, lp=None, g=None): # Probably started in an editor so no INI pass + @property + def show_overlay(self): + """Whether the DRO backdrop is showing. The latch lives on the overlay + part; kept as an attribute because the GTK/Qt widgets set it.""" + return self.scene.overlay.backdrop + + @show_overlay.setter + def show_overlay(self, value): + self.scene.overlay.backdrop = bool(value) + def set_cone_basesize(self, size): if size > 2 or size < .025: size = 0.5 @@ -604,81 +747,179 @@ def init_glcanondraw(self,trajcoordinates="XYZABCUVW",kinsmodule="trivkins",msg= def realize(self): self.hershey = hershey.Hershey() + self.prim.hershey = self.hershey glPixelStorei(GL_UNPACK_ALIGNMENT, 1) - self.basic_lighting() + glEnable(GL_DEPTH_TEST) + glDepthFunc(GL_LESS) + if glcanon_gl.GL_DEBUG: + self._log_gl_context() self.initialised = 1 + def _log_gl_context(self): + """Report the created OpenGL context (version/renderer/GLSL) to stderr. + + Enabled with GLCANON_GL_DEBUG=1. The preview needs a 3.3 core profile; + this makes the context that was actually obtained visible - e.g. to + confirm 3.3 core on X11/XWayland, or that llvmpipe (software) is in use.""" + import sys + def s(name): + try: + v = glGetString(name) + return v.decode("ascii", "replace") if isinstance(v, bytes) else str(v) + except Exception as e: + return "<%s>" % e + print("glcanon GL context: version=%r renderer=%r glsl=%r" % ( + s(GL_VERSION), s(GL_RENDERER), s(GL_SHADING_LANGUAGE_VERSION)), + file=sys.stderr) + def set_canon(self, canon): self.canon = canon self.canon.foam_z = self.foam_z_height self.canon.foam_w = self.foam_w_height + # Configure the canon's program record with the two things only the + # widget knows, and do it here because load_preview calls set_canon + # immediately before gcode.parse - the last moment at which the + # transform the fill will apply can still be chosen. + # + # Deliberate consequence: a later change to the g5x/g92 offsets that + # rotation_offsets() reads no longer re-transforms the preview. It + # never did in practice - nothing invalidates the program on an offset + # change, only a reload does - so this makes the existing behaviour + # explicit rather than changing it. + canon.configure_program_geometry(self.get_geometry().upper(), + self.rotation_offsets(), + bool(self.is_foam())) + self.scene.program.invalidate() + + # -- core-profile renderer plumbing ------------------------------------ + def _ensure_renderer(self): + if self._renderer is None: + self._renderer = glcanon_gl.GlCanonRenderer() + return self._renderer + + @property + def renderer(self): + """The GL renderer the parts draw through (created on first use).""" + return self._ensure_renderer() + + # -- explicit model-view stack (replaces the legacy GL matrix stack) ---- + # The stack itself lives in glcanon_scene.MatrixStack (self.mv), which the + # scene parts use through the per-frame context with scoped pushes; these + # stay as the shims the pre-scene call sites (and qt5_graphics) use. + @property + def _projection(self): + return self.mv.projection + + @_projection.setter + def _projection(self, matrix): + self.mv.projection = np.asarray(matrix, dtype=np.float64) + + def _mv_reset(self, matrix): + self.mv.reset(matrix) + + def _mv_top(self): + return self.mv.top() + + def _mv_push(self): + self.mv.push_unscoped() + + def _mv_pop(self): + self.mv.pop_unscoped() + + def _mv_mult(self, matrix): + self.mv.mult(matrix) + + def _mv_translate(self, x, y, z): + self.mv.translate(x, y, z) + + def _mv_rotate(self, angle, x, y, z): + self.mv.rotate(angle, x, y, z) + + def _mv_scale(self, x, y, z): + self.mv.scale(x, y, z) + + def preview_mvp(self): + """Model-view-projection for the camera (the frame's world transform).""" + return self._preview_mvp() + + def _preview_mvp(self): + """Model-view-projection for the current camera, as a numpy 4x4. + + Combines the explicit glnav projection (which folds in the eye + translation) and modelview - the same matrices the legacy path loads + onto the GL stack - so the shader path draws in the identical frame. + """ + w = self.winfo_width() + h = self.winfo_height() + return self.get_projection_matrix(w, h) @ self.get_modelview_matrix() - @with_context - def basic_lighting(self): - glLightfv(GL_LIGHT0, GL_POSITION, (1, -1, 1, 0)) - glLightfv(GL_LIGHT0, GL_AMBIENT, self.colors['tool_ambient'] + (0,)) - glLightfv(GL_LIGHT0, GL_DIFFUSE, self.colors['tool_diffuse'] + (0,)) - glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, (1,1,1,0)) - glEnable(GL_LIGHTING) - glEnable(GL_LIGHT0) - glDepthFunc(GL_LESS) - glEnable(GL_DEPTH_TEST) - glMatrixMode(GL_MODELVIEW) - glLoadIdentity() + def rotation_offsets(self): + """glcanon_bake.RotationOffsets matching the C gui_respect_offsets state.""" + g = self.get_geometry().upper() + respect = '!' in g + x = y = z = 0.0 + if respect and self.stat is not None: + x = self.stat.g5x_offset[0] + self.stat.g92_offset[0] + y = self.stat.g5x_offset[1] + self.stat.g92_offset[1] + z = self.stat.g5x_offset[2] + self.stat.g92_offset[2] + return glcanon_bake.RotationOffsets(respect_offsets=respect, + coords=self.trajcoordinates.upper(), + x=x, y=y, z=z) + + _rotation_offsets = rotation_offsets + + @property + def _program_stale(self): + """Whether the program's GPU buffers need re-uploading. The flag lives + on the shared ProgramResource the draw and pick paths both use.""" + return self.scene.program.resource.stale + + def _bake_program_geometry(self): + self.scene.program.resource.upload(self.frame_context()) + + def _stack_mvp(self): + """MVP folding the current explicit model-view stack into the frame + projection - used by preview elements (axes, limits, cone, offsets, + extents labels) positioned with the _mv_* stack helpers.""" + return self.mv.mvp() + + # -- shared drawing primitives ----------------------------------------- + # These live on glcanon_scene.Primitives (self.prim), which the parts reach + # through the frame context; the shims keep the pre-scene names working. + def _lines_to_array(self, points, color, alpha=1.0): + return self.prim.lines_to_array(points, color, alpha) + + def _limit_color(self, cond): + return self.prim.limit_color(self.colors, cond) + + def _draw_hershey(self, s, color, frac=0.0, bbox=False): + self.prim.draw_hershey(self, s, color, frac, bbox) + + def _cone_mesh_verts(self): + return self.prim.cone_mesh() + + def _draw_cone_core(self, color, mesh_verts=None): + self.prim.draw_cone(self, color, mesh_verts) + + def _dash_period(self): + return glcanon_scene.ProgramPart.dash_period(self.frame_context()) def select(self, x_view, y_view): + """Select the program line under the cursor. Thin delegate to the + Picker; the ID-buffer pick itself lives there.""" if self.canon is None: return - pmatrix = glGetDoublev(GL_PROJECTION_MATRIX) - glMatrixMode(GL_PROJECTION) - glPushMatrix() - glLoadIdentity() - vport = glGetIntegerv(GL_VIEWPORT) - gluPickMatrix(x_view, vport[3]-y_view, 5, 5, vport) - glMultMatrixd(pmatrix) - glMatrixMode(GL_MODELVIEW) - - glSelectBuffer(self.select_buffer_size) - glRenderMode(GL_SELECT) - glInitNames() - glPushName(0) - - if self.get_show_rapids(): - glCallList(self.dlist('select_rapids', gen=self.make_selection_list)) - glCallList(self.dlist('select_norapids', gen=self.make_selection_list)) - - try: - buffer = glRenderMode(GL_RENDER) - except: - buffer = [] - - if buffer: - min_depth, max_depth, names = (buffer[0].near, buffer[0].far, buffer[0].names) - for point in buffer: - if min_depth < point.near: - min_depth, max_depth, names = (point.near, point.far, point.names) - self.set_highlight_line(names[0]) - else: - self.set_highlight_line(None) - - glMatrixMode(GL_PROJECTION) - glPopMatrix() - glMatrixMode(GL_MODELVIEW) - - def dlist(self, listname, n=1, gen=lambda n: None): - if listname not in self._dlists: - base = glGenLists(n) - self._dlists[listname] = base, n - gen(base) - return self._dlists[listname][0] + self.set_highlight_line( + self.picker.pick(self.frame_context(), x_view, y_view)) def stale_dlist(self, listname): - if listname not in self._dlists: return - base, count = self._dlists.pop(listname) - glDeleteLists(base, count) - - def __del__(self): - for base, count in list(self._dlists.values()): - glDeleteLists(base, count) + # GPU-cache invalidation (was a GL display-list free). The baked program + # VBOs replace the old program_*/select_* display lists, so invalidating + # any of those names marks the bake stale and the next frame rebuilds the + # buffers - preserving the legacy call surface (e.g. subclass calls to + # stale_dlist('program_norapids')). Other names are now no-ops. + if listname in ('program_rapids', 'program_norapids', + 'select_rapids', 'select_norapids'): + self.scene.program.invalidate() def update_highlight_variable(self,line): self.highlight_line = line @@ -687,16 +928,12 @@ def set_current_line(self, line): pass def set_highlight_line(self, line): if line == self.get_highlight_line(): return self.update_highlight_variable(line) - highlight = self.dlist('highlight') - glNewList(highlight, GL_COMPILE) + # The highlight geometry is drawn by glcanon_scene.HighlightPart; here + # we only recompute the centrepoint the selection recentres the view on. if line is not None and self.canon is not None: if self.is_foam(): - glPushMatrix() - glTranslatef(0, 0, self.get_foam_z()) x, y, z = self.canon.highlight(line, "XY") - glTranslatef(0, 0, self.get_foam_w()-self.get_foam_z()) u, v, w = self.canon.highlight(line, "UV") - glPopMatrix() x = (x+u)/2 y = (y+v)/2 z = (self.get_foam_z() + self.get_foam_w())/2 @@ -708,12 +945,10 @@ def set_highlight_line(self, line): z = (self.canon.min_extents[Z] + self.canon.max_extents[Z])/2 else: x, y, z = 0.0, 0.0, 0.0 - glEndList() self.set_centerpoint(x, y, z) @with_context_swap def redraw_perspective(self): - w = self.winfo_width() h = self.winfo_height() glViewport(0, 0, w, h) @@ -722,20 +957,15 @@ def redraw_perspective(self): glClearColor(*(self.colors['back'] + (0,))) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - glMatrixMode(GL_PROJECTION) - glLoadIdentity() - gluPerspective(self.fovy, float(w)/float(h), self.near, self.far + self.distance) - - gluLookAt(0, 0, self.distance, - 0, 0, 0, - 0., 1., 0.) - glMatrixMode(GL_MODELVIEW) - glPushMatrix() + # Explicit projection (glnav folds the eye translation in) and model-view + # seed - no GL matrix stack. get_projection_matrix branches on + # self.perspective, true here. + self._projection = self.get_projection_matrix(w, h) + self._mv_reset(self.get_modelview_matrix()) try: self.redraw() finally: - glFlush() # Tidy up - glPopMatrix() # Restore the matrix + glFlush() @with_context_swap def redraw_ortho(self): @@ -749,241 +979,25 @@ def redraw_ortho(self): glClearColor(*(self.colors['back'] + (0,))) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) - glMatrixMode(GL_PROJECTION) - glLoadIdentity() - ztran = self.distance - k = (abs(ztran or 1)) ** .55555 - l = k * h / w - glOrtho(-k, k, -l, l, -1000, 1000.) - - gluLookAt(0, 0, 1, - 0, 0, 0, - 0., 1., 0.) - glMatrixMode(GL_MODELVIEW) - glPushMatrix() + self._projection = self.get_projection_matrix(w, h) + self._mv_reset(self.get_modelview_matrix()) try: self.redraw() finally: - glFlush() # Tidy up - glPopMatrix() # Restore the matrix + glFlush() def color_limit(self, cond): - if cond: - glColor3f(*self.colors['label_limit']) - else: - glColor3f(*self.colors['label_ok']) + # The colour is now applied per label via _limit_color/_draw_hershey; + # this keeps returning the predicate (used as the out-of-limit bbox flag + # and by any external callers). return cond def show_extents(self): - s = self.stat - g = self.canon - - if g is None: return - - # Dimensions - view = self.get_view() - is_metric = self.get_show_metric() - dimscale = is_metric and 25.4 or 1.0 - fmt = is_metric and "%.1f" or "%.2f" - - machine_limit_min, machine_limit_max = self.soft_limits() - - pullback = max(g.max_extents[X] - g.min_extents[X], - g.max_extents[Y] - g.min_extents[Y], - g.max_extents[Z] - g.min_extents[Z], - 2) * .1 - - dashwidth = pullback/4 - charsize = dashwidth * 1.5 - halfchar = charsize * .5 - - if view == VZ or view == VP: - z_pos = g.min_extents[VZ] - zdashwidth = 0 - else: - z_pos = g.min_extents[VZ] - pullback - zdashwidth = dashwidth - - #draw dimension lines - self.color_limit(0) - glBegin(GL_LINES) - - # x dimension - if view != VX and g.max_extents[X] > g.min_extents[X]: - y_pos = g.min_extents[Y] - pullback - #dimension line - glVertex3f(g.min_extents[X], y_pos, z_pos) - glVertex3f(g.max_extents[X], y_pos, z_pos) - #line perpendicular to dimension line at min extent - glVertex3f(g.min_extents[X], y_pos - dashwidth, z_pos - zdashwidth) - glVertex3f(g.min_extents[X], y_pos + dashwidth, z_pos + zdashwidth) - #line perpendicular to dimension line at max extent - glVertex3f(g.max_extents[X], y_pos - dashwidth, z_pos - zdashwidth) - glVertex3f(g.max_extents[X], y_pos + dashwidth, z_pos + zdashwidth) - - # y dimension - if view != VY and g.max_extents[Y] > g.min_extents[Y]: - x_pos = g.min_extents[X] - pullback - #dimension line - glVertex3f(x_pos, g.min_extents[Y], z_pos) - glVertex3f(x_pos, g.max_extents[Y], z_pos) - #line perpendicular to dimension line at min extent - glVertex3f(x_pos - dashwidth, g.min_extents[Y], z_pos - zdashwidth) - glVertex3f(x_pos + dashwidth, g.min_extents[Y], z_pos + zdashwidth) - #line perpendicular to dimension line at max extent - glVertex3f(x_pos - dashwidth, g.max_extents[Y], z_pos - zdashwidth) - glVertex3f(x_pos + dashwidth, g.max_extents[Y], z_pos + zdashwidth) - - # z dimension - if view != VZ and g.max_extents[Z] > g.min_extents[Z]: - x_pos = g.min_extents[X] - pullback - y_pos = g.min_extents[Y] - pullback - #dimension line - glVertex3f(x_pos, y_pos, g.min_extents[Z]) - glVertex3f(x_pos, y_pos, g.max_extents[Z]) - #line perpendicular to dimension line at min extent - glVertex3f(x_pos - dashwidth, y_pos - zdashwidth, g.min_extents[Z]) - glVertex3f(x_pos + dashwidth, y_pos + zdashwidth, g.min_extents[Z]) - #line perpendicular to dimension line at max extent - glVertex3f(x_pos - dashwidth, y_pos - zdashwidth, g.max_extents[Z]) - glVertex3f(x_pos + dashwidth, y_pos + zdashwidth, g.max_extents[Z]) - - glEnd() - - # Labels - # get_show_relative == True calculates extents from the local origin - # get_show_relative == False calculates extents from the machine origin - if self.get_show_relative(): - offset = self.to_internal_units(s.g5x_offset + s.g92_offset) - else: - offset = 0, 0, 0 - #Z extent labels - if view != VZ and g.max_extents[Z] > g.min_extents[Z]: - if view == VX: - x_pos = g.min_extents[X] - pullback - y_pos = g.min_extents[Y] - 6.0*dashwidth - else: - x_pos = g.min_extents[X] - 6.0*dashwidth - y_pos = g.min_extents[Y] - pullback - #Z MIN extent - bbox = self.color_limit(g.min_extents_notool[Z] < machine_limit_min[Z]) - glPushMatrix() - f = fmt % ((g.min_extents[Z]-offset[Z]) * dimscale) - glTranslatef(x_pos, y_pos, g.min_extents[Z] - halfchar) - glScalef(charsize, charsize, charsize) - glRotatef(-90, 0, 1, 0) - glRotatef(-90, 0, 0, 1) - if view != VX: - glRotatef(-90, 0, 1, 0) - self.hershey.plot_string(f, 0, bbox) - glPopMatrix() - #Z MAX extent - bbox = self.color_limit(g.max_extents_notool[Z] > machine_limit_max[Z]) - glPushMatrix() - f = fmt % ((g.max_extents[Z]-offset[Z]) * dimscale) - glTranslatef(x_pos, y_pos, g.max_extents[Z] - halfchar) - glScalef(charsize, charsize, charsize) - glRotatef(-90, 0, 1, 0) - glRotatef(-90, 0, 0, 1) - if view != VX: - glRotatef(-90, 0, 1, 0) - self.hershey.plot_string(f, 0, bbox) - glPopMatrix() - self.color_limit(0) - glPushMatrix() - #Z Midpoint - f = fmt % ((g.max_extents[Z] - g.min_extents[Z]) * dimscale) - glTranslatef(x_pos, y_pos, (g.max_extents[Z] + g.min_extents[Z])/2) - glScalef(charsize, charsize, charsize) - if view != VX: - glRotatef(-90, 0, 0, 1) - glRotatef(-90, 0, 1, 0) - self.hershey.plot_string(f, .5, bbox) - glPopMatrix() - #Y extent labels - if view != VY and g.max_extents[Y] > g.min_extents[Y]: - x_pos = g.min_extents[X] - 6.0*dashwidth - #Y MIN extent - bbox = self.color_limit(g.min_extents_notool[Y] < machine_limit_min[Y]) - glPushMatrix() - f = fmt % ((g.min_extents[Y] - offset[Y]) * dimscale) - glTranslatef(x_pos, g.min_extents[Y] + halfchar, z_pos) - glRotatef(-90, 0, 0, 1) - glRotatef(-90, 0, 0, 1) - if view == VX: - glRotatef(90, 0, 1, 0) - glTranslatef(dashwidth*1.5, 0, 0) - glScalef(charsize, charsize, charsize) - self.hershey.plot_string(f, 0, bbox) - glPopMatrix() - #Y MAX extent - bbox = self.color_limit(g.max_extents_notool[Y] > machine_limit_max[Y]) - glPushMatrix() - f = fmt % ((g.max_extents[Y] - offset[Y]) * dimscale) - glTranslatef(x_pos, g.max_extents[Y] + halfchar, z_pos) - glRotatef(-90, 0, 0, 1) - glRotatef(-90, 0, 0, 1) - if view == VX: - glRotatef(90, 0, 1, 0) - glTranslatef(dashwidth*1.5, 0, 0) - glScalef(charsize, charsize, charsize) - self.hershey.plot_string(f, 0, bbox) - glPopMatrix() - - self.color_limit(0) - glPushMatrix() - #Y midpoint - f = fmt % ((g.max_extents[Y] - g.min_extents[Y]) * dimscale) - glTranslatef(x_pos, (g.max_extents[Y] + g.min_extents[Y])/2, - z_pos) - glRotatef(-90, 0, 0, 1) - if view == VX: - glRotatef(-90, 1, 0, 0) - glTranslatef(0, halfchar, 0) - glScalef(charsize, charsize, charsize) - self.hershey.plot_string(f, .5) - glPopMatrix() - #X extent labels - if view != VX and g.max_extents[X] > g.min_extents[X]: - y_pos = g.min_extents[Y] - 6.0*dashwidth - #X MIN extent - bbox = self.color_limit(g.min_extents_notool[X] < machine_limit_min[X]) - glPushMatrix() - f = fmt % ((g.min_extents[X] - offset[X]) * dimscale) - glTranslatef(g.min_extents[X] - halfchar, y_pos, z_pos) - glRotatef(-90, 0, 0, 1) - if view == VY: - glRotatef(90, 0, 1, 0) - glTranslatef(dashwidth*1.5, 0, 0) - glScalef(charsize, charsize, charsize) - self.hershey.plot_string(f, 0, bbox) - glPopMatrix() - #X MAX extent - bbox = self.color_limit(g.max_extents_notool[X] > machine_limit_max[X]) - glPushMatrix() - f = fmt % ((g.max_extents[X] - offset[X]) * dimscale) - glTranslatef(g.max_extents[X] - halfchar, y_pos, z_pos) - glRotatef(-90, 0, 0, 1) - if view == VY: - glRotatef(90, 0, 1, 0) - glTranslatef(dashwidth*1.5, 0, 0) - glScalef(charsize, charsize, charsize) - self.hershey.plot_string(f, 0, bbox) - glPopMatrix() - - self.color_limit(0) - glPushMatrix() - #X midpoint - f = fmt % ((g.max_extents[X] - g.min_extents[X]) * dimscale) - glTranslatef((g.max_extents[X] + g.min_extents[X])/2, y_pos, - z_pos) - if view == VY: - glRotatef(-90, 1, 0, 0) - glTranslatef(0, halfchar, 0) - glScalef(charsize, charsize, charsize) - self.hershey.plot_string(f, .5) - glPopMatrix() + """Program dimension lines and labels. Kept as a method for the GUIs + that call it directly; the drawing lives in the scene's extents part.""" + if self.canon is not None: + self.scene.extents.draw(self.frame_context()) def draw_cube(self, min_extents, max_extents, color=(1, 1, 1)): """ @@ -992,57 +1006,12 @@ def draw_cube(self, min_extents, max_extents, color=(1, 1, 1)): :param max_extents: Tuple of X,Y,Z Maximum Limits :param color: Tuple of RGB color values """ - glColor3f(color[0], color[1], color[2]) - glBegin(GL_LINES) - # Bottom of part bounding box - glVertex3f(min_extents[X], min_extents[Y], min_extents[Z]) - glVertex3f(max_extents[X], min_extents[Y], min_extents[Z]) - - glVertex3f(max_extents[X], min_extents[Y], min_extents[Z]) - glVertex3f(max_extents[X], max_extents[Y], min_extents[Z]) - - glVertex3f(max_extents[X], max_extents[Y], min_extents[Z]) - glVertex3f(min_extents[X], max_extents[Y], min_extents[Z]) - - glVertex3f(min_extents[X], max_extents[Y], min_extents[Z]) - glVertex3f(min_extents[X], min_extents[Y], min_extents[Z]) - - # Top of part bounding box - glVertex3f(min_extents[X], min_extents[Y], max_extents[Z]) - glVertex3f(max_extents[X], min_extents[Y], max_extents[Z]) - - glVertex3f(max_extents[X], min_extents[Y], max_extents[Z]) - glVertex3f(max_extents[X], max_extents[Y], max_extents[Z]) - - glVertex3f(max_extents[X], max_extents[Y], max_extents[Z]) - glVertex3f(min_extents[X], max_extents[Y], max_extents[Z]) - - glVertex3f(min_extents[X], max_extents[Y], max_extents[Z]) - glVertex3f(min_extents[X], min_extents[Y], max_extents[Z]) - - # Middle connections - glVertex3f(min_extents[X], min_extents[Y], min_extents[Z]) - glVertex3f(min_extents[X], min_extents[Y], max_extents[Z]) - - glVertex3f(max_extents[X], min_extents[Y], min_extents[Z]) - glVertex3f(max_extents[X], min_extents[Y], max_extents[Z]) - - glVertex3f(max_extents[X], max_extents[Y], min_extents[Z]) - glVertex3f(max_extents[X], max_extents[Y], max_extents[Z]) - - glVertex3f(min_extents[X], max_extents[Y], min_extents[Z]) - glVertex3f(min_extents[X], max_extents[Y], max_extents[Z]) - - glEnd() + self.prim.draw_cube(self, min_extents, max_extents, color) def draw_bounding_box(self): """Draw a bounding box around the extents of the program if we skip loading the entire part.""" - g = self.canon - - if g is None: - return - - self.draw_cube(g.min_extents, g.max_extents, color=(0.57, 0.68, 0.71)) + if self.canon is not None: + self.scene.bounding_box.draw(self.frame_context()) def to_internal_linear_unit(self, v, unit=None): if unit is None: @@ -1085,138 +1054,19 @@ def get_grid(self): if self.canon and self.canon.grid: return self.canon.grid return 5./25.4 - def comp(self, sx_sy, cx_cy): - (sx, sy) = sx_sy - (cx, cy) = cx_cy - return -(sx*cx + sy*cy) / (sx*sx + sy*sy) - - def param(self, x1_y1, dx1_dy1, x3_y3, dx3_dy3): - (x1, y1) = x1_y1 - (dx1, dy1) = dx1_dy1 - (x3, y3) = x3_y3 - (dx3, dy3) = dx3_dy3 - den = (dy3)*(dx1) - (dx3)*(dy1) - if den == 0: return 0 - num = (dx3)*(y1-y3) - (dy3)*(x1-x3) - return num * 1. / den - - def draw_grid_lines(self, space, ox_oy, dx_dy, lim_min, lim_max, - inverse_permutation): - # draw a series of line segments of the form - # dx(x-ox) + dy(y-oy) + k*space = 0 - # for integers k that intersect the AABB [lim_min, lim_max] - (ox, oy) = ox_oy - (dx, dy) = dx_dy - lim_pts = [ - (lim_min[0], lim_min[1]), - (lim_max[0], lim_min[1]), - (lim_min[0], lim_max[1]), - (lim_max[0], lim_max[1])] - od = self.comp((dy, -dx), (ox, oy)) - d0, d1 = minmax(*(self.comp((dy, -dx), i)-od for i in lim_pts)) - k0 = int(math.ceil(d0/space)) - k1 = int(math.floor(d1/space)) - delta = (dx, dy) - for k in range(k0, k1+1): - d = k*space - # Now we're drawing the line dx(x-ox) + dx(y-oy) + d = 0 - p0 = (ox - dy * d, oy + dx * d) - # which is the same as the line p0 + u * delta - - # but we only want the part that's inside the box lim_pts... - if dx and dy: - times = [ - self.param(p0, delta, lim_min[:2], (0, 1)), - self.param(p0, delta, lim_min[:2], (1, 0)), - self.param(p0, delta, lim_max[:2], (0, 1)), - self.param(p0, delta, lim_max[:2], (1, 0))] - times.sort() - t0, t1 = times[1], times[2] # Take the middle two times - elif dx: - times = [ - self.param(p0, delta, lim_min[:2], (0, 1)), - self.param(p0, delta, lim_max[:2], (0, 1))] - times.sort() - t0, t1 = times[0], times[1] # Take the only two times - else: - times = [ - self.param(p0, delta, lim_min[:2], (1, 0)), - self.param(p0, delta, lim_max[:2], (1, 0))] - times.sort() - t0, t1 = times[0], times[1] # Take the only two times - x0, y0 = p0[0] + delta[0]*t0, p0[1] + delta[1]*t0 - x1, y1 = p0[0] + delta[0]*t1, p0[1] + delta[1]*t1 - - glVertex3f(*inverse_permutation((x0, y0, lim_min[2]))) - glVertex3f(*inverse_permutation((x1, y1, lim_min[2]))) - - def draw_grid_permuted(self, rotation, permutation, inverse_permutation): - grid_size=self.get_grid_size() - if not grid_size: return - - glLineWidth(1) - glColor3f(*self.colors['grid']) - - s = self.stat - tlo_offset = permutation(self.to_internal_units(s.tool_offset)[:3]) - g5x_offset = permutation(self.to_internal_units(s.g5x_offset)[:3])[:2] - g92_offset = permutation(self.to_internal_units(s.g92_offset)[:3])[:2] - - lim_min, lim_max = self.soft_limits() - lim_min = permutation(lim_min) - lim_max = permutation(lim_max) - - lim_min = tuple(a-b for a,b in zip(lim_min, tlo_offset)) - lim_max = tuple(a-b for a,b in zip(lim_max, tlo_offset)) - - if self.get_show_relative(): - cos_rot = math.cos(rotation) - sin_rot = math.sin(rotation) - offset = ( - g5x_offset[0] + g92_offset[0] * cos_rot - - g92_offset[1] * sin_rot, - g5x_offset[1] + g92_offset[0] * sin_rot - + g92_offset[1] * cos_rot) - else: - offset = 0., 0. - cos_rot = 1. - sin_rot = 0. - glDepthMask(False) - glBegin(GL_LINES) - self.draw_grid_lines(grid_size, offset, (cos_rot, sin_rot), - lim_min, lim_max, inverse_permutation) - self.draw_grid_lines(grid_size, offset, (sin_rot, -cos_rot), - lim_min, lim_max, inverse_permutation) - glEnd() - glDepthMask(True) - def draw_grid(self): + """Draw the ground grid. Override point: plasmac2 replaces this method + on the instance and calls back into draw_grid_permuted.""" + self.scene.grid.draw_default(self.frame_context()) - view = self.get_view() - rotation = math.radians(self.stat.rotation_xy % 90) - - # perspective view (code stolen from the QtPlasmac crew) - if view == VP: - def permutation(x_y_z2): - return x_y_z2[0], x_y_z2[1], x_y_z2[2] # XY Z - def inverse_permutation(x_y_z3): - return x_y_z3[0], x_y_z3[1], x_y_z3[2] # XY Z - self.draw_grid_permuted(rotation, permutation, inverse_permutation) - - # all other views - else: - permutations = [ - lambda x_y_z: (x_y_z[2], x_y_z[1], x_y_z[0]), # YZ X - lambda x_y_z1: (x_y_z1[2], x_y_z1[0], x_y_z1[1]), # ZX Y - lambda x_y_z2: (x_y_z2[0], x_y_z2[1], x_y_z2[2]), # XY Z - ] - inverse_permutations = [ - lambda z_y_x: (z_y_x[2], z_y_x[1], z_y_x[0]), # YZ X - lambda z_x_y: (z_x_y[1], z_x_y[2], z_x_y[0]), # ZX Y - lambda x_y_z3: (x_y_z3[0], x_y_z3[1], x_y_z3[2]), # XY Z - ] - self.draw_grid_permuted(rotation, permutations[view], - inverse_permutations[view]) + def draw_grid_permuted(self, rotation, permutation, inverse_permutation): + """Override point: plasmac2 replaces ``draw_grid`` and calls back in + here directly, bypassing the scene - so this must enter the grid's + scope itself, or the grid silently starts writing depth.""" + ctx = self.frame_context() + with self.scene.grid.scope(ctx): + self.scene.grid.draw_permuted(ctx, rotation, permutation, + inverse_permutation) def all_joints_homed(self): for i in range (self.stat.joints): @@ -1288,20 +1138,68 @@ def idx_for_home_or_limit_icon(self,string): return -1 # no icon display def show_icon_init(self): - self.show_icon_home_list = [] - self.show_icon_limit_list = [] - - def show_icon(self,idx,icon): - # only show icon once for idx for home,limit icons - # accommodates hal_gremlin override format_dro() - # and prevents display for both Rad and Dia - if icon is homeicon: - if idx in self.show_icon_home_list: return - self.show_icon_home_list.append(idx) - if icon is limiticon: - if idx in self.show_icon_limit_list: return - self.show_icon_limit_list.append(idx) - glBitmap(13, 16, 0, 3, 17, 0, icon.tobytes()) + self.scene.overlay.reset_icons() + + def show_icon(self, idx, icon): + """Draw a home/limit icon on the current DRO line. Kept as a method + because hal_gremlin's format_dro() override interacts with it.""" + self.scene.overlay.show_icon(idx, icon) + + def build_scene(self): + """The scene this widget draws. Overridable by a hosting GUI that wants + a different set or order of parts.""" + return glcanon_scene.PreviewScene() + + def frame_context(self) -> glcanon_scene.FrameContext: + """Build the narrow context the scene parts draw from. + + This is the whole coupling between the drawing code and this widget: + the parts see these fields and nothing else. Flags every frame reads + anyway are resolved here; hooks a hosting GUI may override, and values + only some parts need, are passed as bound methods. + """ + return glcanon_scene.FrameContext( + mv=self.mv, prim=self.prim, renderer=self.renderer, + colors=self.colors, + + stat=self.stat, canon=self.canon, lp=self.lp, + geometry=self.get_geometry(), is_lathe=self.is_lathe(), + is_foam=self.is_foam(), foam_z=self.get_foam_z(), + foam_w=self.get_foam_w(), limits=self.soft_limits(), + joints_mode=self.get_joints_mode(), + + view=self.get_view(), + width=self.winfo_width(), height=self.winfo_height(), + show_program=self._resolve_show_program(), + show_rapids=self.get_show_rapids(), + show_extents=self.get_show_extents(), + show_offsets=self.get_show_offsets(), + show_limits=self.get_show_limits(), + show_tool=self.get_show_tool(), + show_live_plot=self.get_show_live_plot(), + show_relative=self.get_show_relative(), + show_metric=self.get_show_metric(), + show_small_origin=self.show_small_origin, + program_alpha=self.get_program_alpha(), + grid_size=self.get_grid_size(), + highlight_line=self.get_highlight_line(), + enable_dro=self.enable_dro, + cone_basesize=self.cone_basesize, + disable_cone_scaling=self.disable_cone_scaling, + view_tool_min_dia=self.view_tool_min_dia, + + preview_mvp=self._preview_mvp, + to_internal_units=self.to_internal_units, + to_internal_linear_unit=self.to_internal_linear_unit, + color_limit=self.color_limit, + draw_grid=self.draw_grid, + posstrs=self.posstrs, + font_info=self.get_font_info, + current_tool=self.get_current_tool, + icon_index=self.idx_for_home_or_limit_icon, + show_icon=self.show_icon, + user_plot=getattr(self, 'user_plot', None), + ) def redraw(self): s = self.stat @@ -1311,336 +1209,17 @@ def redraw(self): s.g5x_offset[1] + s.g92_offset[1], s.g5x_offset[2] + s.g92_offset[2]) - machine_limit_min, machine_limit_max = self.soft_limits() - - glDisable(GL_LIGHTING) - glMatrixMode(GL_MODELVIEW) - self.draw_grid() + self.scene.draw(self.frame_context()) + def _resolve_show_program(self): + """get_show_program(), refused for a file too large to preview.""" show_program = self.get_show_program() + s = self.stat if os.path.exists(s.file): if 0 < self.max_file_size < os.stat(s.file).st_size : print("File too large to load, disabling preview.") show_program = False - - if show_program: - if self.get_program_alpha(): - glDisable(GL_DEPTH_TEST) - glEnable(GL_BLEND) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - - if self.get_show_rapids(): - glCallList(self.dlist('program_rapids', gen=self.make_main_list)) - glCallList(self.dlist('program_norapids', gen=self.make_main_list)) - glCallList(self.dlist('highlight')) - - if self.get_program_alpha(): - glDisable(GL_BLEND) - glEnable(GL_DEPTH_TEST) - - if self.get_show_extents(): - self.show_extents() - else: - self.show_extents() - self.draw_bounding_box() - - try: - self.user_plot() - except: - pass - if self.get_show_live_plot() or show_program: - - alist = self.dlist(('axes', self.get_view()), gen=self.draw_axes) - glPushMatrix() - if self.get_show_relative() and (s.g5x_offset[X] or s.g5x_offset[Y] or s.g5x_offset[Z] or - s.g92_offset[X] or s.g92_offset[Y] or s.g92_offset[Z] or - s.rotation_xy): - olist = self.dlist('draw_small_origin', - gen=self.draw_small_origin) - if self.show_small_origin: - glCallList(olist) - g5x_offset = self.to_internal_units(s.g5x_offset)[:3] - g92_offset = self.to_internal_units(s.g92_offset)[:3] - - - if self.get_show_offsets() and (g5x_offset[X] or g5x_offset[Y] or g5x_offset[Z]): - glBegin(GL_LINES) - glVertex3f(0,0,0) - glVertex3f(*g5x_offset) - glEnd() - - i = s.g5x_index - if i<7: - label = "G5%d" % (i+3) - else: - label = "G59.%d" % (i-6) - glPushMatrix() - glScalef(0.2,0.2,0.2) - if self.is_lathe(): - g5xrot=math.atan2(g5x_offset[0], -g5x_offset[2]) - glRotatef(90, 1, 0, 0) - glRotatef(-90, 0, 0, 1) - else: - g5xrot=math.atan2(g5x_offset[1], g5x_offset[0]) - glRotatef(math.degrees(g5xrot), 0, 0, 1) - glTranslatef(0.5, 0.5, 0) - self.hershey.plot_string(label, 0.1) - glPopMatrix() - - glTranslatef(*g5x_offset) - glRotatef(s.rotation_xy, 0, 0, 1) - - - if self.get_show_offsets() and (g92_offset[X] or g92_offset[Y] or g92_offset[Z]): - glBegin(GL_LINES) - glVertex3f(0,0,0) - glVertex3f(*g92_offset) - glEnd() - - glPushMatrix() - glScalef(0.2,0.2,0.2) - if self.is_lathe(): - g92rot=math.atan2(g92_offset[X], -g92_offset[Z]) - glRotatef(90, 1, 0, 0) - glRotatef(-90, 0, 0, 1) - else: - g92rot=math.atan2(g92_offset[Y], g92_offset[X]) - glRotatef(math.degrees(g92rot), 0, 0, 1) - glTranslatef(0.5, 0.5, 0) - self.hershey.plot_string("G92", 0.1) - glPopMatrix() - - glTranslatef(*g92_offset) - - if self.is_foam(): - glTranslatef(0, 0, self.get_foam_z()) - glCallList(alist) - uwalist = self.dlist(('axes_uw', self.get_view()), gen=lambda n: self.draw_axes(n, 'UVW')) - glTranslatef(0, 0, self.get_foam_w()-self.get_foam_z()) - glCallList(uwalist) - else: - glCallList(alist) - glPopMatrix() - - if self.get_show_limits(): - glTranslatef(*[-pos for pos in self.to_internal_units(s.tool_offset)[:3]]) - glLineWidth(1) - self.draw_cube(machine_limit_min, machine_limit_max, color=self.colors['limits']) - - glTranslatef(*self.to_internal_units(s.tool_offset)[:3]) - - if self.get_show_live_plot(): - glDepthFunc(GL_LEQUAL) - glLineWidth(3) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glEnable(GL_BLEND) - glPushMatrix() - lu = 1/((s.linear_units or 1)*25.4) - glScalef(lu, lu, lu) - glMatrixMode(GL_PROJECTION) - glPushMatrix() - glTranslatef(0,0,.003) - - self.lp.call() - - glPopMatrix() - glMatrixMode(GL_MODELVIEW) - glPopMatrix() - glDisable(GL_BLEND) - glLineWidth(1) - glDepthFunc(GL_LESS) - - if self.get_show_tool(): - pos = self.lp.last(self.get_show_live_plot()) - if pos is None: pos = [0] * 6 - rx, ry, rz = pos[3:6] - pos = self.to_internal_units(pos[:3]) - if self.is_foam(): - glEnable(GL_COLOR_MATERIAL) - glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE) - glPushMatrix() - glTranslatef(pos[0], pos[1], self.get_foam_z()) - glRotatef(180, 1, 0, 0) - cone = self.dlist("cone", gen=self.make_cone) - glColor3f(*self.colors['cone_xy']) - glCallList(cone) - glPopMatrix() - u = self.to_internal_linear_unit(rx) - v = self.to_internal_linear_unit(ry) - glPushMatrix() - glTranslatef(u, v, self.get_foam_w()) - glColor3f(*self.colors['cone_uv']) - glCallList(cone) - glPopMatrix() - else: - glPushMatrix() - glTranslatef(*pos) - sign = 1 - g = re.split(" *(-?[XYZABCUVW])", self.get_geometry()) - g = "".join(reversed(g)) - - for ch in g: # Apply in original non-reversed GEOMETRY order - if ch == '-': - sign = -1 - elif ch == 'A': - glRotatef(rx*sign, 1, 0, 0) - sign = 1 - elif ch == 'B': - glRotatef(ry*sign, 0, 1, 0) - sign = 1 - elif ch == 'C': - glRotatef(rz*sign, 0, 0, 1) - sign = 1 - else: - sign = 1 # reset sign for non-rotational axis "XYZUVW" - glEnable(GL_BLEND) - glEnable(GL_CULL_FACE) - glBlendFunc(GL_ONE, GL_CONSTANT_ALPHA) - - current_tool = self.get_current_tool() - if current_tool is None or current_tool.diameter <= self.view_tool_min_dia: - if self.canon and not self.disable_cone_scaling: - g = self.canon - - cone_scale = max(g.max_extents[X] - g.min_extents[X], - g.max_extents[Y] - g.min_extents[Y], - g.max_extents[Z] - g.min_extents[Z], - 2 ) * self.cone_basesize - else: - cone_scale = self.cone_basesize - if self.is_lathe(): - glRotatef(90, 0, 1, 0) - # if Rotation = 180 - back tool - if self.stat.rotation_xy == 180: - glRotatef(180, 1, 0, 0) - cone = self.dlist("cone", gen=self.make_cone) - glScalef(cone_scale, cone_scale, cone_scale) - glColor3f(*self.colors['cone']) - glCallList(cone) - else: - if current_tool != self.cached_tool: - self.cache_tool(current_tool) - glColor3f(*self.colors['cone']) - glCallList(self.dlist('tool')) - glPopMatrix() - - glMatrixMode(GL_PROJECTION) - glPushMatrix() - glLoadIdentity() - ypos = self.winfo_height() - glOrtho(0.0, self.winfo_width(), 0.0, ypos, -1.0, 1.0) - glMatrixMode(GL_MODELVIEW) - - glPushMatrix() - glLoadIdentity() - - limit, homed, posstrs, droposstrs = self.posstrs() - - charwidth, linespace, base = self.get_font_info() - - pixel_width = charwidth * max(len(p) for p in posstrs) - - if self.show_overlay: - glDepthFunc(GL_ALWAYS) - glDepthMask(GL_FALSE) - glEnable(GL_BLEND) - glBlendFunc(GL_ONE, GL_CONSTANT_ALPHA) - glColor3f(*self.colors['overlay_background']) - glBlendColor(0,0,0,1-self.colors['overlay_alpha']) - glBegin(GL_QUADS) - glVertex3f(0, ypos, 1) - glVertex3f(0, ypos - 8 - linespace*len(posstrs), 1) - glVertex3f(pixel_width+42, ypos - 8 - linespace*len(posstrs), 1) - glVertex3f(pixel_width+42, ypos, 1) - glEnd() - glDisable(GL_BLEND) - - maxlen = 0 - ypos -= linespace+5 - glColor3f(*self.colors['overlay_foreground']) - - self.show_icon_init() - stringstart_xpos = 15 - #----------------------------------------------------------------------- - if self.get_show_offsets(): thestring = droposstrs - else: thestring = posstrs - - # allows showing/hiding overlay DRO readout - if self.enable_dro: - self.show_overlay = True - for string in thestring: - maxlen = max(maxlen, len(string)) - glRasterPos2i(stringstart_xpos, ypos) - for char in string: - glCallList(base + ord(char)) - - idx = self.idx_for_home_or_limit_icon(string) - if (idx == -1): # skip icon display for this line - if (len(string) != 0): ypos -= linespace - continue - - glRasterPos2i(0, ypos) - if (idx == -2 or idx == -6): # use allhomedicon - self.show_icon(idx,allhomedicon) - if (idx == -4 or idx == -6): # use somelimiticon - self.show_icon(idx,somelimiticon) - if (idx <= -2): - ypos -= linespace - continue - - if ( self.get_joints_mode() - or (self.stat.kinematics_type == linuxcnc.KINEMATICS_IDENTITY) - ): - if homed[idx]: - self.show_icon(idx,homeicon) - if limit[idx]: - self.show_icon(idx,limiticon) - ypos -= linespace - continue - - # extra joint after homing, world mode - if ((self.stat.num_extrajoints>0) and (not self.get_joints_mode())): - self.show_icon(idx,homeicon) - if limit[idx]: - self.show_icon(idx,limiticon) - - ypos -= linespace - else: - self.show_overlay = False - - glDepthFunc(GL_LESS) - glDepthMask(GL_TRUE) - - glPopMatrix() - glMatrixMode(GL_PROJECTION) - glPopMatrix() - glMatrixMode(GL_MODELVIEW) - - def cache_tool(self, current_tool): - self.cached_tool = current_tool - glNewList(self.dlist('tool'), GL_COMPILE) - if self.is_lathe() and current_tool and current_tool.orientation != 0: - glBlendColor(0,0,0,self.colors['lathetool_alpha']) - self.lathetool(current_tool) - else: - glBlendColor(0,0,0,self.colors['tool_alpha']) - if self.is_lathe(): - glRotatef(90, 0, 1, 0) - else: - dia = current_tool.diameter - r = self.to_internal_linear_unit(dia) / 2. - q = gluNewQuadric() - glEnable(GL_LIGHTING) - gluCylinder(q, r, r, 8*r, 32, 1) - glPushMatrix() - glRotatef(180, 1, 0, 0) - gluDisk(q, 0, r, 32, 1) - glPopMatrix() - glTranslatef(0,0,8*r) - gluDisk(q, 0, r, 32, 1) - glDisable(GL_LIGHTING) - gluDeleteQuadric(q) - glEndList() + return show_program def lathe_historical_config(self,trajcoordinates): # detect historical lathe config with dummy joint 1 @@ -1797,191 +1376,8 @@ def dro_format(self,s,spd,dtg,limit,homed,positions,axisdtg,g5x_offset,g92_offse posstrs.append(jstr) return limit, homed, posstrs, droposstrs - def draw_small_origin(self, n): - glNewList(n, GL_COMPILE) - r = 2.0/25.4 - glColor3f(*self.colors['small_origin']) - - glBegin(GL_LINE_STRIP) - for i in range(37): - theta = (i*10)*math.pi/180.0 - glVertex3f(r*math.cos(theta),r*math.sin(theta),0.0) - glEnd() - glBegin(GL_LINE_STRIP) - for i in range(37): - theta = (i*10)*math.pi/180.0 - glVertex3f(0.0, r*math.cos(theta), r*math.sin(theta)) - glEnd() - glBegin(GL_LINE_STRIP) - for i in range(37): - theta = (i*10)*math.pi/180.0 - glVertex3f(r*math.cos(theta),0.0, r*math.sin(theta)) - glEnd() - - glBegin(GL_LINES) - glVertex3f(-r, -r, 0.0) - glVertex3f( r, r, 0.0) - glVertex3f(-r, r, 0.0) - glVertex3f( r, -r, 0.0) - - glVertex3f(-r, 0.0, -r) - glVertex3f( r, 0.0, r) - glVertex3f(-r, 0.0, r) - glVertex3f( r, 0.0, -r) - - glVertex3f(0.0, -r, -r) - glVertex3f(0.0, r, r) - glVertex3f(0.0, -r, r) - glVertex3f(0.0, r, -r) - glEnd() - glEndList() - - def draw_axes(self, n, letters="XYZ"): - glNewList(n, GL_COMPILE) - - view = self.get_view() - - glColor3f(*self.colors['axis_x']) - glBegin(GL_LINES) - glVertex3f(1.0,0.0,0.0) - glVertex3f(0.0,0.0,0.0) - glEnd() - - if view != VX: - glPushMatrix() - if self.is_lathe(): - glTranslatef(1.3, -0.1, 0) - glTranslatef(0, 0, -0.1) - glRotatef(-90, 0, 1, 0) - glRotatef(90, 1, 0, 0) - glTranslatef(0.1, 0, 0) - else: - glTranslatef(1.2, -0.1, 0) - if view == VY: - glTranslatef(0, 0, -0.1) - glRotatef(90, 1, 0, 0) - glScalef(0.2, 0.2, 0.2) - self.hershey.plot_string(letters[0], 0.5) - glPopMatrix() - - glColor3f(*self.colors['axis_y']) - glBegin(GL_LINES) - glVertex3f(0.0,0.0,0.0) - glVertex3f(0.0,1.0,0.0) - glEnd() - - if view != VY: - glPushMatrix() - glTranslatef(0, 1.2, 0) - if view == VX: - glTranslatef(0, 0, -0.1) - glRotatef(90, 0, 1, 0) - glRotatef(90, 0, 0, 1) - glScalef(0.2, 0.2, 0.2) - self.hershey.plot_string(letters[1], 0.5) - glPopMatrix() - - glColor3f(*self.colors['axis_z']) - glBegin(GL_LINES) - glVertex3f(0.0,0.0,0.0) - glVertex3f(0.0,0.0,1.0) - glEnd() - - if view != VZ: - glPushMatrix() - glTranslatef(0, 0, 1.2) - if self.is_lathe(): - glRotatef(-90, 0, 1, 0) - if view == VX: - glRotatef(90, 0, 1, 0) - glRotatef(90, 0, 0, 1) - elif view == VY or view == VP: - glRotatef(90, 1, 0, 0) - if self.is_lathe(): - glTranslatef(0, -.1, 0) - glScalef(0.2, 0.2, 0.2) - self.hershey.plot_string(letters[2], 0.5) - glPopMatrix() - - glEndList() - - def make_cone(self, n): - q = gluNewQuadric() - glNewList(n, GL_COMPILE) - glBlendColor(0,0,0,self.colors['tool_alpha']) - glEnable(GL_LIGHTING) - gluCylinder(q, 0, .1, .25, 32, 1) - glPushMatrix() - glTranslatef(0,0,.25) - gluDisk(q, 0, .1, 32, 1) - glPopMatrix() - glDisable(GL_LIGHTING) - glEndList() - gluDeleteQuadric(q) - - - lathe_shapes = [ - None, # 0 - (1,-1), (1,1), (-1,1), (-1,-1), # 1..4 - (0,-1), (1,0), (0,1), (-1,0), # 5..8 - (0,0) # 9 - ] - def lathetool(self, current_tool): - glDepthFunc(GL_ALWAYS) - diameter, frontangle, backangle, orientation = current_tool[-4:] - w = 3/8. - glDisable(GL_CULL_FACE)#lathe tool needs to be visible form both sides - radius = self.to_internal_linear_unit(diameter) / 2. - glColor3f(*self.colors['lathetool']) - glBegin(GL_LINES) - glVertex3f(-radius/2.0,0.0,0.0) - glVertex3f(radius/2.0,0.0,0.0) - glVertex3f(0.0,0.0,-radius/2.0) - glVertex3f(0.0,0.0,radius/2.0) - glEnd() - - glNormal3f(0,1,0) - - if orientation == 9: - glBegin(GL_TRIANGLE_FAN) - for i in range(37): - t = i * math.pi / 18 - glVertex3f(radius * math.cos(t), 0.0, radius * math.sin(t)) - glEnd() - else: - dx, dy = self.lathe_shapes[orientation] - - min_angle = min(backangle, frontangle) * math.pi / 180 - max_angle = max(backangle, frontangle) * math.pi / 180 - - sinmax = math.sin(max_angle) - cosmax = math.cos(max_angle) - sinmin = math.sin(min_angle) - cosmin = math.cos(min_angle) - - circleminangle = - math.pi/2 + min_angle - circlemaxangle = - 3*math.pi/2 + max_angle - - - sz = max(w, 3*radius) - - glBegin(GL_TRIANGLE_FAN) - glVertex3f( - radius * dx + radius * math.sin(circleminangle) + sz * sinmin, - 0, - radius * dy + radius * math.cos(circleminangle) + sz * cosmin) - for i in range(37): - t = circleminangle + i * (circlemaxangle - circleminangle)/36. - glVertex3f(radius*dx + radius * math.sin(t), 0.0, radius*dy + radius * math.cos(t)) - - glVertex3f( - radius * dx + radius * math.sin(circlemaxangle) + sz * sinmax, - 0, - radius * dy + radius * math.cos(circlemaxangle) + sz * cosmax) - - glEnd() - glEnable(GL_CULL_FACE) - glDepthFunc(GL_LESS) + #: Kept for external callers; the tool part owns the table it draws from. + lathe_shapes = glcanon_scene.ToolPart.LATHE_SHAPES def extents_info(self): if self.canon: @@ -1992,27 +1388,6 @@ def extents_info(self): size = [3, 3, 3] return mid, size - def make_selection_list(self, unused=None): - select_rapids = self.dlist('select_rapids') - select_program = self.dlist('select_norapids') - glNewList(select_rapids, GL_COMPILE) - if self.canon: self.canon.draw(1, False) - glEndList() - glNewList(select_program, GL_COMPILE) - if self.canon: self.canon.draw(1, True) - glEndList() - - def make_main_list(self, unused=None): - program = self.dlist('program_norapids') - rapids = self.dlist('program_rapids') - glNewList(program, GL_COMPILE) - if self.canon: self.canon.draw(0, True) - glEndList() - - glNewList(rapids, GL_COMPILE) - if self.canon: self.canon.draw(0, False) - glEndList() - def load_preview(self, f, canon, *args): self.set_canon(canon) result, seq = gcode.parse(f, canon, *args) @@ -2022,8 +1397,6 @@ def load_preview(self, f, canon, *args): canon.calc_extents() self.stale_dlist('program_rapids') self.stale_dlist('program_norapids') - self.stale_dlist('select_rapids') - self.stale_dlist('select_norapids') return result, seq diff --git a/lib/python/rs274/glcanon_bake.py b/lib/python/rs274/glcanon_bake.py new file mode 100644 index 00000000000..29c2d7c38c5 --- /dev/null +++ b/lib/python/rs274/glcanon_bake.py @@ -0,0 +1,1463 @@ +# This is a component of AXIS, a front-end for emc +# Copyright 2004, 2005, 2006 Jeff Epler +# Copyright 2026 Alexey Presniakov <309782758+alex-pres@users.noreply.github.com> +# Two further groups of helpers ride along at the end of the file. Neither +# is program geometry, but both are baked the same way - vertex arrays built +# with no OpenGL call in sight - and both are only ever fed to the same +# renderer: the Lambert-shaded tool solids, which are constant meshes +# parameterised by a radius and a height rather than anything the canon +# records, and the live backplot, which is the path the machine has actually +# travelled, streamed out of the C position logger's ring buffer while a +# program runs. The backplot shares the program's *shader* and its 20-byte +# palette-indexed vertex; the solids' interleaved position+normal layout is +# its own and is named as such. +# +# 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. +# +# +# The parsed program, as arrays: the record and the fill that builds it. +# +# ``ProgramGeometry`` is the authoritative form of a loaded G-code program - +# every drawn point with its source line, kind and tool, the events between +# the moves, the dwell and tool-change tables, and the extents. The canon +# owns one and fills it during ``gcode.parse``, through two entry points +# that share one array-only core (``_fill_arrays``): ``add_moves``, which +# still accepts a sequence of the canon's move tuples (what the synthetic +# test streams in tests/gcode-bake/ use), and ``add_moves_raw``, which +# reads a fixed-width float64 staging chunk instead - the shape +# ``GLCanon`` writes ``rotate_and_translate``'s result into directly, with +# no per-move tuple, and the shape a C-delivered batch would arrive in. +# Adopting a C source there is therefore a swap of what feeds +# ``add_moves_raw``, not a new code path. The scene adopts the finished +# geometry and uploads it; the vertex layout the GPU reads is stated here +# too, so the two modules share one statement of it rather than two +# comments asking each other to agree. +# +# The fill reimplements the C `vertex9` GEOMETRY-string transform and the +# `line9` rotary subdivision (see +# src/emc/usr_intf/axis/extensions/emcmodule.cc), vectorised over a batch. +# It contains NO OpenGL calls so it can be unit-tested headless; correctness +# is pinned against the C extension, against an independent reference, and +# against a frozen snapshot of the expansion it replaced, in +# tests/gcode-bake/. +# + +from __future__ import annotations + +import math +from typing import Any, Optional, Sequence + +import numpy as np +import numpy.typing as npt + +# Rotary axis-mask bits, mirroring emcmodule.cc. +AXIS_MASK_A = 0x08 +AXIS_MASK_B = 0x10 +AXIS_MASK_C = 0x20 + +# Geometry the bake does its own maths in: (M, 3) float64 points, in the +# machine frame, before any GPU layout is chosen. Everything above the +# float64 -> float32 boundary in this module speaks this type. +Float64Points = npt.NDArray[np.float64] + +# Interleaved layout: position(3) rgba(4) lineno(1) distance(1) -> 9 float32. +# ``WideVerts`` is the type saying so; rs274.glcanon_gl names the same layout +# through this alias, which is what keeps the two modules' strides one +# statement rather than two comments asking each other to agree. It is what +# the transient grid/axes/label geometry (and the lathe-tool profile fill) +# uses, since that is rebuilt every frame from view-dependent colours that +# don't reduce to a small palette, and what the live backplot falls back to +# when its colours overflow its palette. +FLOATS_PER_VERTEX = 9 +WideVerts = npt.NDArray[np.float32] # (N, FLOATS_PER_VERTEX) + +# The live backplot's layout: position(3 float32), a uint32 holding a palette +# index in its high byte, and an unused distance (float32) -> 20 bytes. Colour +# is not stored per vertex; the shader looks it up in the palette. The packed +# word is carried in a float32 column purely as a bit container - it is never +# read as a number, and the values involved (an index <= 7) can never form a +# NaN pattern that a copy might quiet. See :func:`backplot_vertices`, which is +# the only thing that writes it; the program has its own layout, below. +TRAJ_FLOATS_PER_VERTEX = 5 +TrajectoryVerts = npt.NDArray[np.float32] # (N, TRAJ_FLOATS_PER_VERTEX) + +# The Lambert-shaded solids' layout: position(3) + normal(3) float32, (N, 6), +# drawn GL_TRIANGLES through the cone shader. Not a vertex format either of the +# two above can stand in for, despite all three being float32 - which is the +# reason it is named. +MeshVerts = npt.NDArray[np.float32] # (N, 6) + +# Vertex kinds. Every point in the program array carries one. The order is +# load-bearing, not alphabetical: the drawn kinds come first, so the drawing +# and picking shaders reject a record with the single comparison +# ``kind > u_last_drawn_kind`` rather than an enumeration, and a kind code is +# also directly a palette index for the drawn ones. +KIND_TRAVERSE = 0 +KIND_FEED = 1 +KIND_ARC = 2 +#: The boundary. Everything above it is a record the shaders discard. +LAST_DRAWN_KIND = KIND_ARC +#: A coordinate jump. Carried by the vertex at the jump's *destination*: under +#: GL's last-vertex provoking convention that rejects the segment into it and +#: leaves the segment out of it drawn under its own kind, so a jump costs the +#: one vertex a chain break already cost. +KIND_NOOP = 3 +#: A dwell, or an M1xx user-defined function, at the current position. The +#: marker itself is a separate buffer; this is only the record of the event. +KIND_DWELL = 4 +#: A tool change, at the position it occurred. +KIND_TOOLCHANGE = 5 + +# The three drawn kinds under the older name the canon still tags moves with. +CAT_TRAVERSE = KIND_TRAVERSE +CAT_FEED = KIND_FEED +CAT_ARC = KIND_ARC + +# Entries the shader's palette uniform holds. Three cover the program; the +# live backplot needs six, and the dwell markers one per distinct colour. +# ``PaletteRGBA`` is the type the uniform is uploaded as, and +# rs274.glcanon_gl names it through this alias rather than restating the size. +PALETTE_SIZE = 8 +PaletteRGBA = npt.NDArray[np.float32] # (PALETTE_SIZE, 4) + +# Primitive modes a baked part can ask to be drawn with, named rather than +# given as GL enums so this module stays GL-free. rs274.glcanon_gl maps them. +MODE_LINE_STRIP = "line_strip" +MODE_LINES = "lines" + +# --------------------------------------------------------------------------- +# The program array's vertex layout, stated once here and read by +# rs274.glcanon_gl rather than restated there - as the 20-byte layout above is. +# +# 24 bytes per vertex, in two arrays rather than one interleaved buffer: +# +# per plane position 3 x float32 + distance float32 = 16 B +# shared source line uint32 + kind/tool uint32 = 8 B +# +# The split is what lets foam - which draws the same program on two planes, +# ``XY`` at ``foam_z`` and ``UV`` at ``foam_w`` - store the line, kind and tool +# columns once for both. Distance sits with the position, not with them, +# because the dash phase accumulates along *transformed* points and the two +# planes' points differ; sharing it would silently change one plane's dashes. +PLANE_DTYPE = np.dtype([('pos', ' None: + self.respect_offsets = bool(respect_offsets) + self.x = float(x) + self.y = float(y) + self.z = float(z) + self.axis_mask = 0 + if self.respect_offsets: + if "A" in coords: + self.axis_mask |= AXIS_MASK_A + if "B" in coords: + self.axis_mask |= AXIS_MASK_B + if "C" in coords: + self.axis_mask |= AXIS_MASK_C + + +DEFAULT_OFFSETS = RotationOffsets() + + +def _rotate_axis(p: Float64Points, comp_a: int, comp_b: int, + angles_deg: npt.NDArray[np.float64], off_a: float, + off_b: float, respect: bool) -> None: + """Rotate columns (comp_a, comp_b) of point array ``p`` by per-row angles. + + Vectorised form of the C rotate_x/y/z: each of the M points is rotated by its + own angle. Matches the C sign convention exactly (subtract offsets in the + respect-offsets branch and do not add them back). + """ + theta = np.radians(angles_deg) + c = np.cos(theta) + s = np.sin(theta) + a = p[:, comp_a] + b = p[:, comp_b] + if respect: + a = a - off_a + b = b - off_b + p[:, comp_a] = a * c - b * s + p[:, comp_b] = a * s + b * c + + +def transform_points(pts9: Any, geometry: str, + ro: RotationOffsets = DEFAULT_OFFSETS) -> Float64Points: + """Map an ``(M, 9)`` array of 9-DOF points to ``(M, 3)`` preview points. + + Vectorised equivalent of the C ``vertex9`` over M points sharing one + geometry string. Columns of ``pts9`` are ``[X Y Z A B C U V W]``. + """ + pts9 = np.asarray(pts9, dtype=np.float64) + if pts9.ndim == 1: + pts9 = pts9[np.newaxis, :] + m = pts9.shape[0] + p = np.zeros((m, 3), dtype=np.float64) + sign = 1.0 + for ch in geometry: + if ch == "-": + sign = -1.0 + elif ch == "X": + p[:, 0] += pts9[:, 0] * sign; sign = 1.0 + elif ch == "Y": + p[:, 1] += pts9[:, 1] * sign; sign = 1.0 + elif ch == "Z": + p[:, 2] += pts9[:, 2] * sign; sign = 1.0 + elif ch == "U": + p[:, 0] += pts9[:, 6] * sign; sign = 1.0 + elif ch == "V": + p[:, 1] += pts9[:, 7] * sign; sign = 1.0 + elif ch == "W": + p[:, 2] += pts9[:, 8] * sign; sign = 1.0 + elif ch == "A": + if ro.axis_mask & AXIS_MASK_A: + _rotate_axis(p, 1, 2, pts9[:, 3] * sign, ro.y, ro.z, + ro.respect_offsets) + sign = 1.0 + elif ch == "B": + if ro.axis_mask & AXIS_MASK_B: + # rotate_y couples (x, z); the C form is (x' , z') with x first. + _rotate_axis(p, 0, 2, pts9[:, 4] * sign, ro.x, ro.z, + ro.respect_offsets) + sign = 1.0 + elif ch == "C": + if ro.axis_mask & AXIS_MASK_C: + _rotate_axis(p, 0, 1, pts9[:, 5] * sign, ro.x, ro.y, + ro.respect_offsets) + sign = 1.0 + # other chars ('!', ';', ...) are no-ops, sign preserved (C default) + return p + + +def _unrotate_xy(pts: Float64Points, rotation_xy: float, + g5x_xy: Sequence[float]) -> Float64Points: + """``pts`` with the g5x XY rotation taken back out, about the g5x origin. + + The vectorised form of what ``GLCanon.unrotate_preview`` does per move. + Z is left alone, exactly as there. + """ + if not rotation_xy: + return pts + angle = math.radians(-rotation_xy) + cos_a = math.cos(angle) + sin_a = math.sin(angle) + tx = pts[:, 0] - g5x_xy[0] + ty = pts[:, 1] - g5x_xy[1] + out = pts.copy() + out[:, 0] = tx * cos_a - ty * sin_a + g5x_xy[0] + out[:, 1] = tx * sin_a + ty * cos_a + g5x_xy[1] + return out + + +class ProgramGeometry: + """The parsed program, as arrays. The authoritative record of what it is. + + Owned by :class:`rs274.glcanon.GLCanon` and filled during the parse, so a + canon driven with no GL context still holds the complete program: every + drawn point with its source line, kind and tool, the events between the + moves, the dwell and tool-change tables, and the extents. The scene adopts + this object and builds GPU buffers from it; it never builds one of its own, + and nothing here knows that OpenGL exists. + + **Storage.** Two arrays, per the layout stated at the top of this module: + one :data:`PLANE_DTYPE` array per drawn plane (position and dash distance, + which are both plane-specific because the transform is) and one shared + :data:`ATTR_DTYPE` array (source line, and the packed kind/tool word). Both + grow by doubling; the move count is not known in advance and a counting + pass would mean holding or re-walking the source. + + **The fill is batched.** :meth:`add_moves` is the only way moves get in, + and it takes any number of them. Nothing on that path costs a numpy call + per move: the batch is converted to arrays once, subdivided once, and + transformed once per drawn plane. A program delivered as one call and the + same program delivered as a thousand produce identical arrays. + + **Events are vertices.** A coordinate jump, a dwell and a tool change each + write a vertex carrying a record-only kind, which the drawing and picking + shaders discard. That is what replaces the chain table: the whole program + is one ``GL_LINE_STRIP`` over a contiguous range, and the discontinuities + live in the data instead of in a list of ranges beside it. A jump is + recorded at its *destination* - see :data:`KIND_NOOP` for why that costs + exactly the one vertex a chain break already cost. + """ + + #: The initial ordinal, held by every vertex before the program's first + #: tool change. ``tool_numbers[0]`` is ``None`` rather than a tool number + #: because the canon is not told what is in the spindle at load - and a + #: value that means "not stated" must not be confusable with T0. + INITIAL_TOOL_ORDINAL = 0 + + def __init__(self, geometry: str = "XYZ", + ro: RotationOffsets = DEFAULT_OFFSETS, + is_foam: bool = False) -> None: + self.geometry = "XYZ" + self.ro = DEFAULT_OFFSETS + self.is_foam = False + self.configure(geometry=geometry, ro=ro, is_foam=is_foam) + + # -- configuration ----------------------------------------------------- + + def configure(self, geometry: Optional[str] = None, + ro: Optional[RotationOffsets] = None, + is_foam: Optional[bool] = None) -> None: + """Set the transform the fill will use, and clear whatever was filled. + + Called by the scene when a canon is set, i.e. immediately before the + parse - which is the only moment the GEOMETRY string and the rotation + offsets can be adopted, since the points are converted once, on the way + in. Changing any of them afterwards would leave the array stale, so + this discards it rather than pretending otherwise. + """ + if geometry is not None: + self.geometry = geometry.upper() + if ro is not None: + self.ro = ro + if is_foam is not None: + self.is_foam = bool(is_foam) + #: The GEOMETRY string of each drawn plane. Foam draws the program + #: twice, once through the XY columns and once through the UV ones. + #: The planes' Z offsets (``foam_z``/``foam_w``) are deliberately NOT + #: baked in: the canon can still move them mid-parse through an + #: ``(AXIS,XY_Z_POS)`` comment, so they belong to the draw, which + #: already translates for them. + self.planes: tuple[str, ...] = ( + ("XY", "UV") if self.is_foam else (self.geometry,)) + self.clear() + + def clear(self) -> None: + """Drop everything filled so far, keeping the configuration.""" + self._n = 0 + self._planes = [np.empty(0, dtype=PLANE_DTYPE) for _ in self.planes] + self._attrs = np.empty(0, dtype=ATTR_DTYPE) + # Per plane: the last vertex's position and its dash distance, so a + # chunk continues the previous chunk's segment and dash phase. + self._prev_pos = [np.zeros(3) for _ in self.planes] + self._dash = [0.0 for _ in self.planes] + #: The 9-DOF point the trajectory is currently at, or ``None`` before + #: the first move. A move starting anywhere else is a jump. + self._cur9: Optional[npt.NDArray[np.float64]] = None + #: (4, 2, 3): the four machine-frame pairs, each ``[min, max]``. + self._extents = np.empty((4, 2, 3), dtype=np.float64) + self._extents[:, 0, :] = 9e99 + self._extents[:, 1, :] = -9e99 + #: Rapid (traverse) path length, over the raw XYZ endpoints. + self._rapid_length = 0.0 + #: Commanded feed rate -> cutting (feed + arc) path length at that + #: rate. Bounded by the number of distinct rates the program + #: commands, not by move count - see :meth:`cutting_time`. + self._cut_length_by_feed: dict[float, float] = {} + #: (2, 3): the bounding box of the transformed points in the array. + self._drawn = np.array([[9e99] * 3, [-9e99] * 3], dtype=np.float64) + self._moves = 0 + #: Ordinal -> T number. Entry 0 is the state before any tool change. + self.tool_numbers: list[Optional[int]] = [None] + self._tool = self.INITIAL_TOOL_ORDINAL + #: ``(lineno, rgba, plane_code, points)`` per dwell, where ``points`` + #: holds one transformed position per drawn plane. + self.dwells: list[tuple[int, RGBA, int, tuple[Any, ...]]] = [] + #: ``(lineno, tool_number, points)`` per tool change, same shape. + self.toolchanges: list[tuple[int, Any, tuple[Any, ...]]] = [] + self._index: Optional[tuple[Any, Any, Any]] = None + + # -- what was filled --------------------------------------------------- + + def __len__(self) -> int: + return self._n + + @property + def n_vertices(self) -> int: + return self._n + + @property + def n_moves(self) -> int: + """Moves reported, as opposed to vertices written.""" + return self._moves + + def plane_array(self, plane: int = 0) -> npt.NDArray[Any]: + """The ``(N,)`` :data:`PLANE_DTYPE` array for one drawn plane.""" + return self._planes[plane][:self._n] + + @property + def attrs(self) -> npt.NDArray[Any]: + """The ``(N,)`` :data:`ATTR_DTYPE` array shared by every plane.""" + return self._attrs[:self._n] + + def positions(self, plane: int = 0) -> npt.NDArray[np.float32]: + return self._planes[plane]['pos'][:self._n] + + def distance(self, plane: int = 0) -> npt.NDArray[np.float32]: + return self._planes[plane]['dist'][:self._n] + + @property + def lines(self) -> npt.NDArray[np.uint32]: + return self._attrs['line'][:self._n] + + @property + def kindtool(self) -> npt.NDArray[np.uint32]: + return self._attrs['kindtool'][:self._n] + + @property + def kinds(self) -> npt.NDArray[np.uint32]: + return self.kindtool & KIND_MASK + + @property + def tools(self) -> npt.NDArray[np.uint32]: + return (self.kindtool >> TOOL_SHIFT) & TOOL_MASK + + def tool_number(self, ordinal: int) -> Optional[int]: + """The T word an ordinal stands for; ``None`` for the initial state.""" + return self.tool_numbers[int(ordinal)] + + # -- extents ----------------------------------------------------------- + + @property + def extents(self) -> npt.NDArray[np.float64]: + """``[min, max]`` over the moves' endpoints, in the machine frame.""" + return self._extents[0] + + @property + def extents_notool(self) -> npt.NDArray[np.float64]: + """The same with each move's tool-length offset added back in.""" + return self._extents[1] + + @property + def extents_zero_rxy(self) -> npt.NDArray[np.float64]: + """The same with each move's own g5x XY rotation removed.""" + return self._extents[2] + + @property + def extents_notool_zero_rxy(self) -> npt.NDArray[np.float64]: + """Rotation removed, then the tool offset added - in that order. + + The order is not arbitrary and not symmetric: ``unrotate_preview`` + rebuilds each move tuple with rotated coordinates and the *unrotated* + tool offset still attached, and the C then adds that offset to the + rotated point. Rotating the tool-corrected point instead would give a + different box on any program with both a rotation and a tool offset. + """ + return self._extents[3] + + @property + def drawn_extents(self) -> npt.NDArray[np.float64]: + """``[min, max]`` over the transformed points actually in the array. + + A different quantity from :attr:`extents`, and named apart because it + coincides with it only when the GEOMETRY transform is the identity. It + is the box a dash period or a view fit wants; the four pairs above are + the machine-frame boxes the properties dialog and the DRO show. + + The foam planes' Z offsets are not included, for the same reason they + are not baked into the positions. + """ + return self._drawn + + @property + def is_empty(self) -> bool: + """No move was ever reported, so the extents are still sentinels.""" + return self._moves == 0 + + # -- the fill ---------------------------------------------------------- + + def add_moves(self, moves: Sequence[Any], kinds: Any, + rotation_xy: float = 0.0, + g5x_xy: Sequence[float] = (0.0, 0.0)) -> None: + """Fill ``moves`` into the array. The only way points get in. + + ``moves`` is a sequence of the canon's move tuples - ``(lineno, p1_9, + p2_9, ..., (xo, yo, zo))``, the tool-length offset always last, which + is the one thing the traverse and feed arities agree on - and ``kinds`` + the parallel kind codes. ``rotation_xy`` and ``g5x_xy`` are the values + in effect *for these moves*: they are what the rotation-removed extents + are accumulated with, and the caller re-batches when they change, which + is why they are per call rather than per object. + + Any number of moves per call, including zero. The batch size changes + nothing about the result. + """ + k = len(moves) + if k == 0: + return + # One conversion per batch, not per move. Everything below is whole- + # array work over these. + lines = np.fromiter((m[0] for m in moves), dtype=np.int64, count=k) + p1 = np.array([m[1] for m in moves], dtype=np.float64) + p2 = np.array([m[2] for m in moves], dtype=np.float64) + offsets = np.array([m[-1] for m in moves], dtype=np.float64) + # A feed/arc tuple carries a feed rate at index 3 and a traverse tuple + # does not - the one arity difference between them (see the docstring + # above) - so tuple length, not kind_in, says which moves have one. A + # caller that hands over a feed-rate-less tuple (as some synthetic + # test streams do) simply contributes no cutting time. + feedrates = np.array( + [(m[3] if len(m) >= 5 else 0.0) for m in moves], dtype=np.float64) + kind_in = self._as_kind_array(kinds, k) + self._fill_arrays(lines, p1, p2, offsets, feedrates, kind_in, + rotation_xy, g5x_xy) + + def add_moves_raw(self, chunk: Any, kinds: Any, + rotation_xy: float = 0.0, + g5x_xy: Sequence[float] = (0.0, 0.0)) -> None: + """Fill from a raw staging chunk instead of a sequence of move tuples. + + ``chunk`` is a ``(k, STAGING_ROW_WIDTH)`` float64 array, one row per + move: lineno, p1 (9), p2 (9), feedrate, offset (3) - see + :data:`STAGING_ROW_WIDTH` and the column slices beside it. This is the + shape ``GLCanon`` stages moves into directly (no per-move tuple), and + the shape a C-delivered batch would arrive in - so a C source there + becomes a source swap onto this method, not a new code path. Behaves + identically to :meth:`add_moves` in every other respect, including + accepting zero rows. + """ + k = len(chunk) + if k == 0: + return + lines = chunk[:, STAGE_LINE].astype(np.int64) + p1 = chunk[:, STAGE_P1] + p2 = chunk[:, STAGE_P2] + feedrates = chunk[:, STAGE_FEEDRATE] + offsets = chunk[:, STAGE_OFFSET] + kind_in = self._as_kind_array(kinds, k) + self._fill_arrays(lines, p1, p2, offsets, feedrates, kind_in, + rotation_xy, g5x_xy) + + @staticmethod + def _as_kind_array(kinds: Any, k: int) -> npt.NDArray[np.uint8]: + # ``bytes`` reaches np.asarray as a scalar string, not a buffer, so a + # caller handing over an immutable copy of the canon's pending kinds + # would otherwise fail on a value error about base 10. + if isinstance(kinds, (bytes, bytearray, memoryview)): + kind_in = np.frombuffer(kinds, dtype=np.uint8) + else: + kind_in = np.asarray(kinds, dtype=np.uint8) + if len(kind_in) != k: + raise ValueError("kinds has %d entries for %d moves" + % (len(kind_in), k)) + return kind_in + + def _fill_arrays(self, lines: npt.NDArray[np.int64], p1: Float64Points, + p2: Float64Points, offsets: Float64Points, + feedrates: npt.NDArray[np.float64], + kind_in: npt.NDArray[np.uint8], rotation_xy: float, + g5x_xy: Sequence[float]) -> None: + """The whole-array fill, shared by :meth:`add_moves` and + :meth:`add_moves_raw` once each has produced these six arrays from + whatever it was handed. + """ + k = len(lines) + self._moves += k + + # 1. Extents, from the raw endpoints, before any subdivision. The + # interpolated rotary points do not exist yet and must not: the + # box is of the moves, not of the polyline drawn for them. + self._accumulate_extents(p1, p2, offsets, rotation_xy, g5x_xy) + + # 1b. Path lengths, from the same raw endpoints. + self._accumulate_lengths(p1, p2, kind_in, feedrates) + + # 2. How many points each move contributes, and whether it needs a + # record vertex at its start because the trajectory jumped to get + # there. + steps = _rotary_steps_batch(p1, p2) + prev = np.empty_like(p1) + prev[1:] = p2[:-1] + jump = np.empty(k, dtype=bool) + jump[1:] = np.any(p1[1:] != prev[1:], axis=1) + # Nothing has been drawn yet: the first move's start point is a jump + # by definition, which is also what starts the strip. + jump[0] = (True if self._cur9 is None + else bool(np.any(p1[0] != self._cur9))) + counts = steps + jump + total = int(counts.sum()) + + # 3. Expand. ``t == 0`` reproduces p1 exactly, which is what makes the + # jump record fall out of the same expression as the interpolation + # rather than needing a branch. + ends = np.cumsum(counts) + move_idx = np.repeat(np.arange(k), counts) + within = np.arange(total) - (ends - counts)[move_idx] + sub = within - jump[move_idx] + 1 + if steps.max() == 1 and not jump.any(): + pts9 = p2 # the common case: no copy at all + else: + t = (sub / steps[move_idx])[:, np.newaxis] + pts9 = t * p2[move_idx] + (1.0 - t) * p1[move_idx] + + # 4. Columns. The record vertex at a jump carries the kind that says + # "discard the segment into me" and the line number of the move it + # starts, which is what the pre-change chain head carried. + line_col = lines[move_idx].astype(np.uint32) + kind_col = kind_in[move_idx].copy() + kind_col[sub == 0] = KIND_NOOP + + # 5. One transform per plane per batch, never one per move. + points = [transform_points(pts9, geom, self.ro) + for geom in self.planes] + self._write(points, line_col, kind_col) + self._cur9 = p2[-1].copy() + + def mark_dwell(self, lineno: int, point9: Sequence[float], + rgba: Sequence[float], plane: int = 0) -> None: + """Record a dwell (or an M1xx) at the current position. + + Writes one vertex - the tool does not move, so its incoming segment is + degenerate whatever kind it carries - and one row in :attr:`dwells` + holding the *transformed* position on each drawn plane. The marker is + drawn from that table, by the scene, into its own buffer; the array + records only that the dwell happened, where, on which line and under + which tool. + """ + points = self._mark(lineno, point9, KIND_DWELL) + self.dwells.append((int(lineno), tuple(float(c) for c in rgba), + int(plane), points)) + + def mark_toolchange(self, lineno: int, point9: Sequence[float], + tool_number: Any) -> None: + """Record a tool change at the current position and start a new tool. + + The record vertex carries the *new* ordinal: it marks where the new + tool's work begins. The jump that follows is not written here - the + canon sets ``first_move`` on a tool change, so the next move's start + point differs from this position and :meth:`add_moves` records the + jump itself. That keeps the two facts - "the tool changed" and "the + position jumped" - as the two vertices they are, without the caller + having to remember to say both. + """ + if len(self.tool_numbers) > MAX_TOOL_ORDINAL: + # 65535 tool changes in one program. Reuse the last ordinal rather + # than wrap onto another tool's entry, which would silently + # mis-attribute the rest of the program. + self._tool = MAX_TOOL_ORDINAL + else: + self._tool = len(self.tool_numbers) + self.tool_numbers.append( + None if tool_number is None else int(tool_number)) + points = self._mark(lineno, point9, KIND_TOOLCHANGE) + self.toolchanges.append((int(lineno), self.tool_numbers[self._tool], + points)) + + def mark_jump(self, lineno: int, point9: Sequence[float]) -> None: + """Record that the trajectory moved without drawing. + + Rarely needed explicitly: :meth:`add_moves` already records a jump for + any move that does not start where the previous one ended, which is + every jump a canon can produce. It exists for a caller that knows the + position moved before it has the move that follows - and it is + idempotent with the automatic record, because it leaves the current + position at ``point9``, so the following move no longer looks like a + jump. + """ + self._mark(lineno, point9, KIND_NOOP) + self._cur9 = np.asarray(point9, dtype=np.float64).copy() + + def _mark(self, lineno: int, point9: Sequence[float], + kind: int) -> tuple[Any, ...]: + """Write one record vertex, and return its position on each plane.""" + pts9 = np.asarray(point9, dtype=np.float64)[np.newaxis, :] + points = [transform_points(pts9, geom, self.ro) for geom in self.planes] + self._write(points, + np.array([lineno], dtype=np.uint32), + np.array([kind], dtype=np.uint8)) + return tuple(tuple(float(c) for c in p[0]) for p in points) + + # -- writing ----------------------------------------------------------- + + def _write(self, points: Sequence[Float64Points], + line_col: npt.NDArray[np.uint32], + kind_col: npt.NDArray[np.uint8]) -> None: + """Append one batch of already-transformed points and their columns.""" + m = len(line_col) + if m == 0: + return + self._reserve(m) + n = self._n + for i, arr in enumerate(self._planes): + pos = points[i] + dist = self._distances(i, pos, kind_col) + arr['pos'][n:n + m] = pos + arr['dist'][n:n + m] = dist + # Both carried at float64: the stored column is float32, and + # rounding a running total once per batch would make the dash + # phase depend on where the batches happened to fall. + self._prev_pos[i] = pos[-1] + self._dash[i] = float(dist[-1]) + np.minimum(self._drawn[0], pos.min(axis=0), out=self._drawn[0]) + np.maximum(self._drawn[1], pos.max(axis=0), out=self._drawn[1]) + self._attrs['line'][n:n + m] = line_col + self._attrs['kindtool'][n:n + m] = ( + kind_col.astype(np.uint32) + | (np.uint32(self._tool) << np.uint32(TOOL_SHIFT))) + self._n = n + m + self._index = None + + def _reserve(self, extra: int) -> None: + """Grow to hold ``extra`` more vertices, by doubling.""" + need = self._n + extra + cap = len(self._attrs) + if need <= cap: + return + new_cap = max(1024, cap * 2) + while new_cap < need: + new_cap *= 2 + for i, arr in enumerate(self._planes): + grown = np.empty(new_cap, dtype=PLANE_DTYPE) + grown[:self._n] = arr[:self._n] + self._planes[i] = grown + grown_attrs = np.empty(new_cap, dtype=ATTR_DTYPE) + grown_attrs[:self._n] = self._attrs[:self._n] + self._attrs = grown_attrs + + def _distances(self, plane: int, pos: Float64Points, + kind_col: npt.NDArray[np.uint8]) -> npt.NDArray[np.float64]: + """Dash distance for one batch of vertices on one plane. + + The same segmented accumulation ``_chain_distances`` performed per + chain, carried across batches: it climbs through a maximal run of + rapid segments and resets at the start of every such run, which keeps + the magnitude small enough for float32 to resolve the dash period + however long the program is. + + A record vertex is neither: a jump resets the phase, because it is + exactly where a chain used to restart; a dwell or a tool-change record + does not, because it adds no length and the rapid run it interrupts is + still one run. Getting that second case wrong would change the dashes + of any rapid that happens to straddle a ``G4``. + """ + m = len(pos) + step = np.empty((m, 3), dtype=np.float64) + step[0] = pos[0] - self._prev_pos[plane] + step[1:] = np.diff(pos, axis=0) + seglen = np.linalg.norm(step, axis=1) + running = np.cumsum(np.where(kind_col == KIND_TRAVERSE, seglen, 0.0)) + resets = ((kind_col == KIND_FEED) | (kind_col == KIND_ARC) + | (kind_col == KIND_NOOP)) + last = np.maximum.accumulate(np.where(resets, np.arange(m), -1)) + prior = np.where(last >= 0, running[np.maximum(last, 0)], + -self._dash[plane]) + return running - prior + + # -- extents accumulation --------------------------------------------- + + def _accumulate_extents(self, p1: Float64Points, p2: Float64Points, + offsets: Float64Points, rotation_xy: float, + g5x_xy: Sequence[float]) -> None: + """The four machine-frame pairs, from this batch's raw endpoints. + + Both endpoints of every move, which is a shade more than the C + ``gcode.calc_extents`` sees: it accumulates every move's start but + only the end of the last move of each list it is handed, so a move + whose end is no later move's start - the last move before a suppressed + region, say - is invisible to it. The two agree on every fixture in + the corpus; where they could differ this is the larger box and the + right one. + """ + pts = np.concatenate([p1[:, :3], p2[:, :3]]) + tool = np.concatenate([offsets, offsets]) + rot = _unrotate_xy(pts, rotation_xy, g5x_xy) + for i, values in enumerate((pts, pts + tool, rot, rot + tool)): + np.minimum(self._extents[i, 0], values.min(axis=0), + out=self._extents[i, 0]) + np.maximum(self._extents[i, 1], values.max(axis=0), + out=self._extents[i, 1]) + + def _accumulate_lengths(self, p1: Float64Points, p2: Float64Points, + kind_in: npt.NDArray[np.uint8], + feedrates: npt.NDArray[np.float64]) -> None: + """Rapid length and the cutting-length-by-feed-rate table. + + Same raw XYZ endpoints as :meth:`_accumulate_extents`, so this must be + called before subdivision too. The per-rate reduction is vectorised + (``np.unique`` + ``np.bincount``) rather than looped per move; only the + result - one Python dict update per *distinct rate in this batch* - is + a Python-level loop, which is what keeps the table bounded by feed-rate + changes rather than move count. + """ + seglen = np.linalg.norm((p2 - p1)[:, :3], axis=1) + is_traverse = kind_in == CAT_TRAVERSE + self._rapid_length += float(seglen[is_traverse].sum()) + cut_rates = feedrates[~is_traverse] + if len(cut_rates) == 0: + return + cut_lengths = seglen[~is_traverse] + uniq, inverse = np.unique(cut_rates, return_inverse=True) + sums = np.bincount(inverse, weights=cut_lengths, minlength=len(uniq)) + for rate, length in zip(uniq.tolist(), sums.tolist()): + self._cut_length_by_feed[rate] = ( + self._cut_length_by_feed.get(rate, 0.0) + length) + + @property + def rapid_length(self) -> float: + """Total traverse (G0) path length, over the raw XYZ endpoints.""" + return self._rapid_length + + @property + def cutting_length(self) -> float: + """Total feed + arc path length, over the raw XYZ endpoints.""" + return float(sum(self._cut_length_by_feed.values())) + + def cutting_time(self, max_feed_rate: float) -> float: + """Cutting time at ``max_feed_rate``: ``sum(length / min(mf, rate))``. + + Grouped by the distinct commanded rates the fill retained - see + :meth:`_accumulate_lengths` - so this answers for any ``max_feed_rate`` + without re-visiting a single move. Does not include rapid time or + dwell time; callers add those (see ``GLCanon.run_time``). + """ + return sum(length / min(max_feed_rate, rate) + for rate, length in self._cut_length_by_feed.items()) + + # -- the highlight index ---------------------------------------------- + + def _build_index(self) -> tuple[Any, Any, Any]: + """Parallel ``(line, first, count)`` arrays, sorted by line number. + + One vectorised pass over the finished line column, replacing the + per-segment dictionary the bake accumulated - which cost one dict + entry and one list object per source line, tens of megabytes on a + large program - with three int32 arrays and a ``searchsorted``. + + A span is a maximal run of consecutive segments sharing a source line, + expressed as the vertices needed to draw it: ``n`` segments need + ``n + 1`` vertices, starting one before the run's first segment. That + is the same span the pre-change ``_strip_ranges`` produced. + + A segment ending on a record vertex belongs to no line - it is the one + the shader discards - so it takes a key of its own and is dropped. + That is what the pre-change code achieved by starting each chain's + segment list one vertex in (``linenos[1:]`` per chain); here the chain + head is the record vertex itself. Runs left adjacent by the drop are + then merged, exactly as ``_coalesce_ranges`` merged the spans either + side of a chain boundary. + """ + n = self._n + if n < 2: + empty = np.empty(0, dtype=np.int64) + return empty, empty.astype(np.int32), empty.astype(np.int32) + # A segment's key is its end vertex's line, or -1 where that vertex is + # a record and the segment is therefore never drawn. + keys = self.lines[1:].astype(np.int64) + keys = np.where(self.kinds[1:] > LAST_DRAWN_KIND, -1, keys) + breaks = np.flatnonzero(np.diff(keys)) + 1 + starts = np.concatenate([[0], breaks]).astype(np.int64) + stops = np.concatenate([breaks, [len(keys)]]).astype(np.int64) + run_keys = keys[starts] + keep = run_keys >= 0 + run_keys, starts, stops = run_keys[keep], starts[keep], stops[keep] + # Vertex span: the run's segments plus the vertex they start from. + firsts = starts + counts = stops - starts + 1 + order = np.lexsort((firsts, run_keys)) + return _coalesce_spans(run_keys[order], firsts[order], counts[order]) + + @property + def index(self) -> tuple[Any, Any, Any]: + """``(line, first, count)``, built on first use after a fill.""" + if self._index is None: + self._index = self._build_index() + return self._index + + def spans_for_line(self, lineno: Optional[int]) -> list[tuple[int, int]]: + """The ``(first_vertex, vertex_count)`` spans a source line drew. + + Empty for a line that produced no geometry, and for ``None``. + """ + if lineno is None or self._n == 0: + return [] + keys, firsts, counts = self.index + lo = int(np.searchsorted(keys, lineno, side="left")) + hi = int(np.searchsorted(keys, lineno, side="right")) + return [(int(firsts[i]), int(counts[i])) for i in range(lo, hi)] + + +#: Half-extent of a dwell marker's cross arms, in machine units. The legacy +#: renderer's default. +DWELL_CROSS = 0.0078125 + + +def _marker_arms(point: Sequence[float], plane: int, cross: float + ) -> tuple[tuple[tuple[float, ...], tuple[float, ...]], ...]: + """The two crossing segments a dwell marker draws, in its active plane.""" + x, y, z = float(point[0]), float(point[1]), float(point[2]) + if plane == 0: # XY + return (((x - cross, y, z), (x + cross, y, z)), + ((x, y - cross, z), (x, y + cross, z))) + if plane == 1: # XZ + return (((x - cross, y, z), (x + cross, y, z)), + ((x, y, z - cross), (x, y, z + cross))) + return (((x, y - cross, z), (x, y + cross, z)), # YZ + ((x, y, z - cross), (x, y, z + cross))) + + +def _marker_rgba(rgba: Sequence[float]) -> RGBA: + """A dwell's colour, with the alpha rule the baked path has always had. + + ``rgba[3]`` when the tuple carries one, else fully opaque. Deliberately + *not* ``colors['dwell_alpha']``: that belonged to the legacy immediate-mode + path, the baked path has never consulted it, and both colours the canon + appends are three-tuples, so both are opaque. + """ + a = rgba[3] if len(rgba) > 3 else 1.0 + return (float(rgba[0]), float(rgba[1]), float(rgba[2]), float(a)) + + +def _spans_from_pairs(linenos: Sequence[int], + per_item: int) -> tuple[Any, Any, Any]: + """Highlight spans for a buffer of independent ``per_item``-vertex runs. + + The marker buffer draws ``GL_LINES``, so a span is a pair of vertices and + a source line's spans are however many pairs it contributed. Same parallel + ``(line, first, count)`` form the program array uses, so one search serves + both. + """ + n = len(linenos) + if n == 0: + empty = np.empty(0, dtype=np.int64) + return empty, empty.astype(np.int32), empty.astype(np.int32) + keys = np.asarray(linenos, dtype=np.int64) + firsts = np.arange(n, dtype=np.int64) * per_item + counts = np.full(n, per_item, dtype=np.int64) + order = np.lexsort((firsts, keys)) + return _coalesce_spans(keys[order], firsts[order], counts[order]) + + +def dwell_marker_part(geometry: "ProgramGeometry", is_lathe: bool = False, + cross: float = DWELL_CROSS, + offsets: Sequence[float] = (0.0,)) -> BakedPart: + """The dwell markers, in the program array's format, one set per plane. + + The trajectory array records *that* a dwell happened, where, on which line + and under which tool; it draws nothing for it. The crosses are their own + buffer and their own draw, because putting the arms into the strip would + mean no-op hops out to each arm and back, and would fuse two things whose + only relationship is that one occurs during the other. + + Positions come from the geometry's dwell table, which holds them + **transformed**, one per drawn plane - the fix for a marker bake that took + the GEOMETRY string and the rotation offsets as arguments and applied + neither. In foam the program is drawn on two planes and so is each marker. + + Both planes use the marker's own colour: unlike the trajectory, which has + ``straight_feed_xy``/``_uv`` variants, the colour table has no per-plane + entry for a dwell, and the canon stores the resolved colour rather than + its name. The palette is collected from the colours the items actually + carry, so a caller supplying its own still gets it drawn. + """ + n_planes = len(geometry.planes) + entries: list[RGBA] = [] + index: dict[RGBA, int] = {} + kinds: list[int] = [] + linenos: list[int] = [] + plane_points: list[list[tuple[float, ...]]] = [[] for _ in range(n_planes)] + overflowed = False + for lineno, rgba, plane_code, points in geometry.dwells: + colour = _marker_rgba(rgba) + kind = index.get(colour) + if kind is None: + if len(entries) < PALETTE_SIZE: + kind = index[colour] = len(entries) + entries.append(colour) + else: + # Unreachable with the two colours the canon appends and the + # eight the palette holds. Reusing the last entry repaints + # rather than dropping the marker, the lesser of the two. + kind = PALETTE_SIZE - 1 + overflowed = True + plane_code = 1 if is_lathe else int(plane_code) + for i in range(n_planes): + for arm in _marker_arms(points[i], plane_code, cross): + plane_points[i].extend(arm) + kinds.extend([kind] * 4) + linenos.extend([lineno] * 4) + if overflowed: + print("glcanon: more dwell colours than the palette holds; the " + "excess draw in the last entry's colour", flush=True) + + n = len(kinds) + attrs = np.zeros(n, dtype=ATTR_DTYPE) + if n: + attrs['line'] = np.asarray(linenos, dtype=np.uint32) + attrs['kindtool'] = np.asarray(kinds, dtype=np.uint32) + planes = [] + for i in range(n_planes): + arr = np.zeros(n, dtype=PLANE_DTYPE) + if n: + arr['pos'] = np.asarray(plane_points[i], dtype=np.float32) + arr['pos'][:, 2] += offsets[i] if i < len(offsets) else 0.0 + planes.append(arr) + padded = list(entries) + [(0.0, 0.0, 0.0, 1.0)] * (PALETTE_SIZE + - len(entries)) + return {"name": "dwell", "kind": "program_array", + "planes": planes, "attrs": attrs, + "palettes": [padded] * n_planes, + "mode": MODE_LINES, + # No record kinds here, and no rapid to hide or dash: every entry + # is a colour, so the whole palette is drawable. + "dash_cat": -1, "hide_cat": -1, + "last_drawn_kind": PALETTE_SIZE - 1, + "spans": _spans_from_pairs( + [ln for j, ln in enumerate(linenos) if j % 2 == 0], 2)} + + +def program_parts(geometry: "ProgramGeometry", colors: dict[str, Any], + is_foam: bool = False, foam_z: float = 0.0, + foam_w: float = 1.5, is_lathe: bool = False, + cross: float = DWELL_CROSS) -> list[BakedPart]: + """The drawable parts of a filled :class:`ProgramGeometry`. + + Two: the trajectory - one draw over a contiguous range, per drawn plane, + off one shared attribute array - and the dwell markers. + + The planes' Z offsets are applied here rather than in the fill, because + ``foam_z``/``foam_w`` can still move while the program is being parsed (an + ``(AXIS,XY_Z_POS)`` comment sets them), and this runs after it. + """ + suffixes = ("_xy", "_uv") if is_foam else ("",) + offsets = (foam_z, foam_w) if is_foam else (0.0,) + planes = [] + palettes = [] + for i, (suffix, dz) in enumerate(zip(suffixes, offsets)): + arr = geometry.plane_array(i).copy() + if arr.size and dz: + arr['pos'][:, 2] += dz + planes.append(arr) + palettes.append(palette(colors, suffix)) + return [ + {"name": "program", "kind": "program_array", + "planes": planes, "attrs": geometry.attrs, "palettes": palettes, + # The program is the buffer that nominates a dash and a hidden kind, + # and both are its rapid code. Every other buffer nominates neither. + "dash_cat": KIND_TRAVERSE, "hide_cat": KIND_TRAVERSE, + "last_drawn_kind": LAST_DRAWN_KIND, + "mode": MODE_LINE_STRIP, "spans": geometry.index}, + dwell_marker_part(geometry, is_lathe, cross, offsets), + ] + + +def _coalesce_spans(keys: npt.NDArray[np.int64], firsts: npt.NDArray[np.int64], + counts: npt.NDArray[np.int64] + ) -> tuple[Any, Any, Any]: + """Merge spans of the same line that meet end to start. + + ``keys``/``firsts``/``counts`` must already be sorted by ``(key, first)``. + The vectorised form of ``_coalesce_ranges``: a run of spans where each + starts exactly where the last ended becomes one span. + """ + if len(keys) == 0: + return (keys, firsts.astype(np.int32), counts.astype(np.int32)) + adjacent = ((keys[1:] == keys[:-1]) + & (firsts[1:] == firsts[:-1] + counts[:-1])) + heads = np.flatnonzero(np.concatenate([[True], ~adjacent])) + tails = np.concatenate([heads[1:] - 1, [len(keys) - 1]]) + return (keys[heads], + firsts[heads].astype(np.int32), + (firsts[tails] + counts[tails] - firsts[heads]).astype(np.int32)) + + +def _rotary_steps_batch(p1: Float64Points, + p2: Float64Points) -> npt.NDArray[np.int64]: + """Points each move contributes: 1, or the rotary subdivision count. + + The vectorised :func:`_rotary_steps`, with the "no rotary change" answer + folded in as 1 rather than 0 - here the count is what the move emits, and + a move with no rotary change emits its end point. + """ + a1 = p1[:, 3:6] + a2 = p2[:, 3:6] + changed = np.any(a1 != a2, axis=1) + dc = np.abs(a2 - a1).max(axis=1) + steps = np.ceil(np.maximum(10.0, dc / 10.0)).astype(np.int64) + return np.where(changed, steps, 1) + + +def resolve_rgba(colors: dict[str, Any], name: str) -> RGBA: + """Resolve a colour name in the GlCanonDraw ``colors`` table to an rgba tuple. + + The alpha comes from ``_alpha`` when present (matching + ``color_with_alpha``), else 1.0. + """ + rgb = colors[name] + alpha = colors.get(name + "_alpha", 1.0) + return (rgb[0], rgb[1], rgb[2], float(alpha)) + + +# Colour-table names for the palette, in category order. +PALETTE_COLORS = ("traverse", "straight_feed", "arc_feed") + + +def palette(colors: dict[str, Any], suffix: str = "") -> list[RGBA]: + """The shader palette for the drawn kinds: traverse, feed, arc. + + ``suffix`` selects the foam plane's colours (``_xy`` / ``_uv``). Each entry + is the same rgba ``color_with_alpha`` resolved for that kind before the + change, so a kind's drawn colour and alpha are unchanged. Three entries, + one per drawn kind; the record kinds index nothing, because the shader has + already discarded them. The uniform array is padded on upload. + """ + return [resolve_rgba(colors, name + suffix) for name in PALETTE_COLORS] + + +# --------------------------------------------------------------------------- +# The tool solids. +# +# The tool cone and the large-tool cylinder: the only triangle geometry the +# preview draws. They are not program geometry and they are not baked from +# anything the canon records - they are constant meshes parameterised by a +# radius and a height - so they take no ``ProgramGeometry`` and read nothing +# above. Their layout is :data:`MeshVerts`. + + +def cone_mesh(base_radius: float = 0.1, height: float = 0.25, + slices: int = 32) -> MeshVerts: + """Interleaved position(3)+normal(3) triangle mesh for the tool cone. + + Reproduces the legacy ``gluCylinder(q, 0, base_radius, height, slices, 1)`` + plus the ``gluDisk`` cap at ``z = height``: an apex at the origin widening to + a ``base_radius`` circle at ``z = height``, capped. Side normals are tilted + toward the apex by the cone half-angle so Lambert shading matches the + fixed-function lit cone. Returns a float32 ``(N, 6)`` array (GL_TRIANGLES). + """ + slant = math.hypot(base_radius, height) + cos_a = height / slant # component along +radial + sin_a = base_radius / slant # component along -z + verts: list[tuple[float, ...]] = [] + + def ring(i: float) -> tuple[float, float]: + theta = 2.0 * math.pi * i / slices + return math.cos(theta), math.sin(theta) + + for i in range(slices): + c0, s0 = ring(i) + c1, s1 = ring(i + 1) + cm, sm = ring(i + 0.5) + apex = (0.0, 0.0, 0.0, cm * cos_a, sm * cos_a, -sin_a) + b0 = (base_radius * c0, base_radius * s0, height, + c0 * cos_a, s0 * cos_a, -sin_a) + b1 = (base_radius * c1, base_radius * s1, height, + c1 * cos_a, s1 * cos_a, -sin_a) + verts.extend((apex, b0, b1)) + + # Cap disk at z = height, facing +z. Wound CCW as seen from +z (center -> + # d0 -> d1 with increasing angle) so it is the front face under GL_CULL_FACE. + for i in range(slices): + c0, s0 = ring(i) + c1, s1 = ring(i + 1) + center = (0.0, 0.0, height, 0.0, 0.0, 1.0) + d0 = (base_radius * c0, base_radius * s0, height, 0.0, 0.0, 1.0) + d1 = (base_radius * c1, base_radius * s1, height, 0.0, 0.0, 1.0) + verts.extend((center, d0, d1)) + + return np.asarray(verts, dtype=np.float32) + + +def cylinder_mesh(radius: float, height: float, + slices: int = 32) -> MeshVerts: + """Interleaved position(3)+normal(3) triangle mesh for a capped cylinder. + + Reproduces the legacy large-tool solid: ``gluCylinder(q, r, r, height)`` + plus a ``gluDisk`` cap at each end, spanning ``z`` in ``[0, height]`` with + outward radial side normals. Triangles are wound CCW as seen from outside so + they are the front faces under GL_CULL_FACE. Returns float32 ``(N, 6)``. + """ + verts: list[tuple[float, ...]] = [] + + def ring(i: float) -> tuple[float, float]: + theta = 2.0 * math.pi * i / slices + return math.cos(theta), math.sin(theta) + + # Side (outward radial normals). + for i in range(slices): + c0, s0 = ring(i) + c1, s1 = ring(i + 1) + b0 = (radius * c0, radius * s0, 0.0, c0, s0, 0.0) + b1 = (radius * c1, radius * s1, 0.0, c1, s1, 0.0) + t0 = (radius * c0, radius * s0, height, c0, s0, 0.0) + t1 = (radius * c1, radius * s1, height, c1, s1, 0.0) + verts.extend((b0, t0, t1)) + verts.extend((b0, t1, b1)) + + # Bottom cap at z = 0, facing -z (CCW as seen from below). + for i in range(slices): + c0, s0 = ring(i) + c1, s1 = ring(i + 1) + center = (0.0, 0.0, 0.0, 0.0, 0.0, -1.0) + d0 = (radius * c0, radius * s0, 0.0, 0.0, 0.0, -1.0) + d1 = (radius * c1, radius * s1, 0.0, 0.0, 0.0, -1.0) + verts.extend((center, d1, d0)) + + # Top cap at z = height, facing +z (CCW as seen from above). + for i in range(slices): + c0, s0 = ring(i) + c1, s1 = ring(i + 1) + center = (0.0, 0.0, height, 0.0, 0.0, 1.0) + d0 = (radius * c0, radius * s0, height, 0.0, 0.0, 1.0) + d1 = (radius * c1, radius * s1, height, 0.0, 0.0, 1.0) + verts.extend((center, d0, d1)) + + return np.asarray(verts, dtype=np.float32) + + +# --------------------------------------------------------------------------- +# The live backplot's vertex conversion. +# +# The backplot is not program geometry: it is the path the machine has actually +# travelled, streamed out of the C position logger's ring buffer while a +# program runs, and re-uploaded a tail at a time. It shares the program's +# *shader* and its 20-byte palette-indexed vertex (:data:`TrajectoryVerts`), +# and nothing else - it reads no ``ProgramGeometry`` and is filled by no canon. + +# The backplot's own packing convention for the 20-byte vertex's uint32 word: +# the palette index in the high byte, the rest zero. It is not a source line +# number and never was - the backplot is neither picked nor highlighted - so +# the field the program array gives to the line number is simply unused here. +# +# Stated locally rather than shared with the program, which no longer packs +# anything: its line number is a full uint32 field of its own. +BACKPLOT_CAT_SHIFT = 24 + + +def _pack_cat(cats: Any) -> npt.NDArray[np.uint32]: + """One uint32 per vertex holding only the palette index.""" + return np.asarray(cats, dtype=np.uint32) << np.uint32(BACKPLOT_CAT_SHIFT) + + +class ColorPalette: + """An append-only colour -> palette-index map. + + A palette index, once given to a colour, is never given to another one - + even if every vertex using it goes away. That is not tidiness: the live + backplot re-uploads only the changed tail of its ring buffer, so vertices + already resident keep whatever index they were written with. Re-deriving + and re-numbering the palette on a later frame would silently repaint them, + which is the one failure mode here that produces a wrong picture from + code that looks right. + + Colours are keyed on an exact, hashable identity supplied by the caller - + for the backplot the four stored bytes, not a float triple - so two + colours that differ below float rounding still get separate entries and a + colour that recurs gets its original entry back. + + ``size`` entries fit; asking for one more sets :attr:`overflowed` and + yields ``None`` rather than wrapping onto another colour's index. The + caller falls back to the per-vertex-colour format. + """ + + def __init__(self, size: int = PALETTE_SIZE) -> None: + self.size = int(size) + #: rgba float tuples, in index order. Four components each, but + #: built by the caller and stored as given - the length is the + #: caller's contract, not one this class can state, so the element + #: type is the variable-length tuple rather than :data:`RGBA`. + self.entries: list[tuple[float, ...]] = [] + self.overflowed = False + #: key -> index. The key is whatever hashable identity the caller + #: chose for a colour - a float rgba tuple for the dwells, the packed + #: uint32 of the four stored bytes for the backplot. + self._index: dict[Any, int] = {} + + def index_for(self, key: Any, rgba: Sequence[float]) -> Optional[int]: + """The index for ``key``, assigning the next free one if it is new.""" + i = self._index.get(key) + if i is not None: + return i + if len(self.entries) >= self.size: + self.overflowed = True + return None + i = len(self.entries) + self._index[key] = i + self.entries.append(tuple(float(c) for c in rgba)) + return i + + def indices_for_bytes( + self, quads: Any) -> Optional[npt.NDArray[np.int64]]: + """Indices for an ``(n, 4)`` uint8 colour array, as an int array. + + New colours are assigned in first-seen order down the array, so a + prefix of the same point stream always yields the same indices. + Returns ``None`` if the palette cannot hold them all. + + The backplot normally converts only its new tail, but a rebuild - a + capacity grow, or the C ring dropping its oldest points - still brings + the whole buffer through here, up to 100 000 points. It therefore + resolves the colours already known with a ``searchsorted`` over the + handful of palette keys, and only walks the array to assign first-seen + order when a colour it has never seen turns up - which, after the + first frames of a run, is never. + """ + quads = np.ascontiguousarray(quads, dtype=np.uint8).reshape(-1, 4) + if not len(quads): + return np.empty(0, dtype=np.int64) + # One uint32 per colour, so a colour is a single comparable scalar. + # The four bytes are already adjacent and the array is contiguous, so + # this is a reinterpretation rather than four shift-and-or passes over + # every point. The key is only ever compared with other keys made the + # same way, so the byte order the host happens to use does not matter. + packed = quads.view(np.uint32).ravel() + + out = np.empty(len(packed), dtype=np.int64) + unknown = np.ones(len(packed), dtype=bool) + if self._index: + keys = np.fromiter(self._index.keys(), dtype=np.uint32, + count=len(self._index)) + order = np.argsort(keys) + keys_sorted = keys[order] + vals = np.fromiter((self._index[int(k)] for k in keys_sorted), + dtype=np.int64, count=len(keys_sorted)) + pos = np.searchsorted(keys_sorted, packed) + np.clip(pos, 0, len(keys_sorted) - 1, out=pos) + hit = keys_sorted[pos] == packed + out[hit] = vals[pos[hit]] + unknown = ~hit + if not unknown.any(): + return out + + # A colour not seen before. Assign in the order they appear, then + # resolve the stragglers the same way as above. + for i in np.flatnonzero(unknown): + key = int(packed[i]) + if self._index.get(key) is None: + if self.index_for(key, tuple(quads[i] / 255.0)) is None: + return None + out[i] = self._index[key] + return out + + def padded(self) -> list[tuple[float, ...]]: + """The entries padded to ``size``, ready for the palette uniform.""" + out: list[tuple[float, ...]] = [(0.0, 0.0, 0.0, 1.0)] * self.size + out[:len(self.entries)] = self.entries + return out + + +# ``struct logger_point`` from emcmodule.cc: 3 float32 (x,y,z), 4 uint8 rgba, +# 3 float32 (rx,ry,rz or u,v,w), 4 uint8 rgba. 32 bytes, no padding. Matches the +# buffer positionlogger.points() copies out. +LOGGER_DTYPE = np.dtype([ + ('pos', ' TrajectoryVerts | WideVerts: + """Convert a positionlogger point buffer into drawable vertices. + + ``raw`` is the bytes from ``positionlogger.points()``; returns a float32 + array for points ``[first_point, npts)``. Non-foam yields one vertex per + point (drawn GL_LINE_STRIP); foam (is_xyuv) yields two per point - the + XY-plane point then the UV-plane point (drawn GL_LINES) - reproducing the + legacy positionlogger.call vertex stream (full stride vs half stride). + + With a :class:`ColorPalette`, each vertex carries its colour's palette + index in the shared 20-byte layout: ``(M, TRAJ_FLOATS_PER_VERTEX)``, the + index packed into the word whose line-number bits stay zero, distance + zero. The backplot is neither picked nor dashed, so neither is read. + + The palette is keyed on the **stored bytes**, not on the preview's colour + table. The C resolves a motion type to a ``struct color`` of four uint8 + before storing it, and the table those bytes came from held floats that + were truncated on the way in - ``int(0.30 * 255)`` is 76, and 76/255 is + 0.298039. Deriving the palette from the bytes is therefore the only way to + keep each drawn colour identical rather than merely close. + + Without a palette - or if the palette cannot hold every colour the points + carry - the per-vertex-colour ``(M, FLOATS_PER_VERTEX)`` layout is + returned instead, with every colour still exact. The caller distinguishes + the two by the column count. + + In foam mode the C writes the same colour to both plane vertices, so one + index serves the pair. + """ + per_vertex_empty = np.empty((0, FLOATS_PER_VERTEX), dtype=np.float32) + if npts <= first_point: + if palette is None: + return per_vertex_empty + return np.empty((0, TRAJ_FLOATS_PER_VERTEX), dtype=np.float32) + pts = np.frombuffer(raw, dtype=LOGGER_DTYPE, count=npts)[first_point:] + n = len(pts) + + if palette is not None: + # ``c`` and ``c2`` are written from the same ``struct color``, so one + # lookup over the first plane's bytes covers both foam vertices. + cats = palette.indices_for_bytes(pts['c']) + if cats is not None: + m = 2 * n if is_xyuv else n + out = np.zeros((m, TRAJ_FLOATS_PER_VERTEX), dtype=np.float32) + packed = _pack_cat(cats) + if is_xyuv: + out[0::2, 0:3] = pts['pos'] + out[1::2, 0:3] = pts['pos2'] + out[0::2, 3] = packed.view(np.float32) + out[1::2, 3] = packed.view(np.float32) + else: + out[:, 0:3] = pts['pos'] + out[:, 3] = packed.view(np.float32) + return out + # Overflow: fall through to the per-vertex-colour layout rather than + # wrapping an index onto another colour's entry. + + if is_xyuv: + out = np.zeros((2 * n, FLOATS_PER_VERTEX), dtype=np.float32) + out[0::2, 0:3] = pts['pos'] + out[0::2, 3:7] = pts['c'].astype(np.float32) / 255.0 + out[1::2, 0:3] = pts['pos2'] + out[1::2, 3:7] = pts['c2'].astype(np.float32) / 255.0 + else: + out = np.zeros((n, FLOATS_PER_VERTEX), dtype=np.float32) + out[:, 0:3] = pts['pos'] + out[:, 3:7] = pts['c'].astype(np.float32) / 255.0 + return out diff --git a/lib/python/rs274/glcanon_gl.py b/lib/python/rs274/glcanon_gl.py new file mode 100644 index 00000000000..bce4ea0ff72 --- /dev/null +++ b/lib/python/rs274/glcanon_gl.py @@ -0,0 +1,2271 @@ +# OpenGL 3.3 core renderer infrastructure for the rs274 G-code preview. +# +# This module holds the low-level, reusable GL building blocks for the core- +# profile 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 GLSL 330 line +# shader (position + color + line-number + distance-along-line, driven by a +# single MVP uniform, with shader-side dashing). 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. +# +# 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 os +from contextlib import contextmanager +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, KIND_MASK, LineRanges, MeshVerts, + PLANE_DTYPE, PaletteRGBA, 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 + +# Enable with GLCANON_GL_DEBUG=1 to check glGetError after each pass. Off by +# default because a glGetError round-trip stalls the pipeline. +GL_DEBUG = os.environ.get("GLCANON_GL_DEBUG", "") not in ("", "0") + +# --------------------------------------------------------------------------- +# Interleaved per-vertex-colour layout. Each vertex is 9 float32: position(3) +# color-rgba(4) lineno(1) distance(1). +# +# The program trajectory, the dwell markers and the live backplot all draw +# through the trajectory shader in the narrower 20-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 stride here and +# the one in rs274.glcanon_bake are two views of that single statement. +# --------------------------------------------------------------------------- +FLOATS_PER_VERTEX = 9 +VERTEX_STRIDE = FLOATS_PER_VERTEX * 4 # bytes + +ATTR_POSITION = 0 +ATTR_COLOR = 1 +ATTR_LINENO = 2 +ATTR_DISTANCE = 3 + +# (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), + (ATTR_DISTANCE, 1, 8 * 4), +) + +# --------------------------------------------------------------------------- +# The program trajectory's narrower layout: position(3 float32), a uint32 with +# the source line number and the draw category packed together, and distance- +# along-line (float32). 20 bytes, against 36 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 21 bytes, which pads to 24; packing keeps it at 20. +# +# ``TrajectoryVerts``, imported above, is this layout as a type. +# --------------------------------------------------------------------------- +TRAJ_FLOATS_PER_VERTEX = 5 +TRAJ_VERTEX_STRIDE = TRAJ_FLOATS_PER_VERTEX * 4 # bytes + +TRAJ_ATTRIBUTES = ( + (ATTR_POSITION, 3, 0, GL_FLOAT), + (ATTR_LINENO, 1, 3 * 4, GL_UNSIGNED_INT), + (ATTR_DISTANCE, 1, 4 * 4, GL_FLOAT), +) + +# --------------------------------------------------------------------------- +# The program array's layout: 24 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 + distance float32 16 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 positions +# and their dash distances differ (the transform differs, and dash phase +# accumulates along transformed points), 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), + (ATTR_DISTANCE, 1, 3 * 4, 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) + +# 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))) + + +_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. + """ + 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 + + +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). Probed once, cached.""" + global _MAX_LINE_WIDTH + if _MAX_LINE_WIDTH is None: + _MAX_LINE_WIDTH = 3.0 if _try_line_width(3.0) else 1.0 + 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) + + +# --------------------------------------------------------------------------- +# Shader / program helpers +# --------------------------------------------------------------------------- +def compile_shader(source: str, shader_type: GLEnum) -> int: + """Compile one shader stage; raise RuntimeError with the info log on failure.""" + 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) -> None: + vs = compile_shader(vertex_source, GL_VERTEX_SHADER) + fs = compile_shader(fragment_source, GL_FRAGMENT_SHADER) + 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) -> 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. + """ + 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(offset)) + else: + glVertexAttribPointer(location, size, gl_type, GL_FALSE, + stride, ctypes_offset(offset)) + 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 (GLSL 330 core) +# --------------------------------------------------------------------------- +LINE_VERTEX_SHADER = """ +#version 330 core +layout(location = 0) in vec3 in_position; +layout(location = 1) in vec4 in_color; +layout(location = 2) in float in_lineno; +layout(location = 3) in float in_distance; + +uniform mat4 u_mvp; + +out vec4 v_color; +out float v_distance; + +void main() { + gl_Position = u_mvp * vec4(in_position, 1.0); + v_color = in_color; + v_distance = in_distance; +} +""" + +LINE_FRAGMENT_SHADER = """ +#version 330 core +in vec4 v_color; +in float v_distance; + +uniform bool u_dashed; // enable shader dashing on the distance attribute +uniform float u_dash_period; // world units for one on+off cycle +uniform float u_dash_duty; // fraction of the period drawn (0..1) +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() { + if (u_dashed && fract(v_distance / u_dash_period) > u_dash_duty) + discard; + vec4 c = u_use_override ? u_override_color : v_color; + frag_color = vec4(c.rgb, c.a * u_alpha); +} +""" + + +class LineProgram: + """The line shader plus its default uniform state. + + A draw call is: :meth:`use`, set ``u_mvp`` (and any dashing/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. + """ + + def __init__(self) -> None: + self.shader = ShaderProgram(LINE_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_dashed", False) + self.shader.set_bool("u_use_override", False) + + def set_alpha(self, alpha: float) -> None: + self.shader.set_float("u_alpha", alpha) + + def set_dashed(self, enabled: bool, period: float = 1.0, + duty: float = 0.5) -> None: + self.shader.set_bool("u_dashed", enabled) + if enabled: + self.shader.set_float("u_dash_period", period) + self.shader.set_float("u_dash_duty", duty) + + 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() + + +# --------------------------------------------------------------------------- +# Trajectory shader (GLSL 330 core): 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 20-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 = """ +#version 330 core +layout(location = 0) in vec3 in_position; +layout(location = 2) in uint in_packed; // lineno | category << 24 +layout(location = 3) in float in_distance; + +uniform mat4 u_mvp; + +flat out uint v_kind; +out float v_distance; + +void main() { + gl_Position = u_mvp * vec4(in_position, 1.0); + v_kind = in_packed >> 24u; + v_distance = in_distance; +} +""" + +# The program array's vertex stage. Same outputs as the packed one above, off +# the two-buffer 24-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 = """ +#version 330 core +layout(location = 0) in vec3 in_position; +layout(location = 3) in float in_distance; +layout(location = 4) in uint in_kindtool; + +uniform mat4 u_mvp; + +flat out uint v_kind; +out float v_distance; + +void main() { + gl_Position = u_mvp * vec4(in_position, 1.0); + v_kind = in_kindtool & %(kind_mask)uu; + v_distance = in_distance; +} +""" % {"kind_mask": KIND_MASK} + +TRAJ_FRAGMENT_SHADER = """ +#version 330 core +flat in uint v_kind; +in float v_distance; + +uniform vec4 u_palette[%(palette)d]; +uniform bool u_dashed; // enable dashing of the dash kind +uniform float u_dash_period; // world units for one on+off cycle +uniform float u_dash_duty; // fraction of the period drawn (0..1) +uniform float u_alpha; // multiplies the palette alpha (program_alpha) +uniform int u_dash_cat; // kind that dashes, -1 for none +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 dashes and which is hidden are the drawing buffer's to + // nominate, not properties of the number zero: the program nominates its + // rapid code for both, the dwell markers and the live backplot nominate + // neither and so are never dashed or discarded whatever code their + // vertices carry. The highlight pass overrides both - it draws the + // selected line solid, and draws it 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) { + if (cat == u_hide_cat) + discard; + if (cat == u_dash_cat + && u_dashed + && fract(v_distance / u_dash_period) > u_dash_duty) + 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 20-byte layout the + live backplot uses, and the program array's 24-byte one. They differ only + in where the kind comes from, so the colouring, dashing and 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, + dash_cat: int = -1, hide_cat: int = -1, dashed: bool = True, + dash_period: float = 1.0, dash_duty: float = 0.5, + last_drawn_kind: int = PALETTE_SIZE - 1) -> None: + """Start a pass. ``dash_cat``/``hide_cat`` are the drawing buffer's own + kind codes, or -1 for "this buffer has none". + + ``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_dash_cat", dash_cat) + 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_dashed", dashed) + self.shader.set_float("u_dash_period", dash_period) + self.shader.set_float("u_dash_duty", dash_duty) + 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 = """ +#version 330 core +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 = """ +#version 330 core +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} + +TRAJ_PICK_FRAGMENT_SHADER = """ +#version 330 core +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 + +out vec4 frag_color; + +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. Deliberately *not* the dash rejection: a rapid was pickable in + // the gaps of its dash pattern before, and still is. + 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); +} +""" + + +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() + + +class ProgramArrayProgram(TrajectoryProgram): + """:class:`TrajectoryProgram` over the program array's 24-byte layout.""" + + VERTEX_SHADER = PROGRAM_VERTEX_SHADER + + +class ProgramArrayPickProgram(TrajectoryPickProgram): + """:class:`TrajectoryPickProgram` over the program array's layout.""" + + VERTEX_SHADER = PROGRAM_PICK_VERTEX_SHADER + + +# --------------------------------------------------------------------------- +# Pick shader (GLSL 330 core) 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 36-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 = """ +#version 330 core +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 = """ +#version 330 core +flat in int v_id; + +out vec4 frag_color; + +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); +} +""" + + +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 = """ +#version 330 core +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 = """ +#version 330 core +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 RGBA8 + depth framebuffer used by the ID-buffer pick pass. + + Sized to the preview window; :meth:`ensure` reallocates on a size change. + Renderbuffers (not textures) back both attachments because the pass only + reads a small patch back with ``glReadPixels``, never samples them. + """ + + def __init__(self) -> None: + self.fbo = 0 + self.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 = glGenRenderbuffers(1) + glBindRenderbuffer(GL_RENDERBUFFER, self.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_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) + 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) + color = glReadPixels(rx, ry, 5, 5, GL_RGBA, GL_UNSIGNED_BYTE) + depth = glReadPixels(rx, ry, 5, 5, GL_DEPTH_COMPONENT, GL_FLOAT) + 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. + + 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) + d = np.frombuffer(bytes(depth), dtype=np.float32).reshape(5, 5) + # 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)) + hit = ids > 0 + if not hit.any(): + return None + nearest = int(np.argmin(np.where(hit, d, np.inf))) + 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: + 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", ()), + dash_cat=part.get("dash_cat", -1), + 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")) + 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"], + dash_cat=part.get("dash_cat", -1), + 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, + dash_period: float = 1.0, dash_duty: float = 0.5) -> None: + """Draw the whole program: the trajectory, then the dwell markers. + + The trajectory is one multi-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 + dashes and which the show-rapids toggle hides, so a buffer that + nominates neither is unaffected by either. + """ + for buf in self.buffers.values(): + buf.begin(renderer, mvp, alpha, show_rapids, + dash_period, dash_duty) + 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._line: LineProgram | 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] = [] + + # -- 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 + + # -- lazy program/resource creation ------------------------------------ + def line_program(self) -> LineProgram: + if self._line is None: + self._line = LineProgram() + return self._line + + 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, + dashed: bool = False, dash_period: float = 1.0, + alpha: float = 1.0) -> None: + """Draw an interleaved (N,9) 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. + """ + verts = np.ascontiguousarray(verts, dtype=np.float32) + if verts.size == 0: + return + if self._scratch is None: + self._scratch = CategoryBuffers() + self._scratch.upload(verts) + line = self.line_program() + line.begin(mvp, alpha) + line.set_dashed(dashed, dash_period) + 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,9) 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: + 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._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 24-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] = [] + 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]]] = [] + # This buffer's own kind codes: which dashes, 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.dash_cat = -1 + 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. + 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]]] = (), + dash_cat: int = -1, hide_cat: int = -1, + last_drawn_kind: int = PALETTE_SIZE - 1, + mode: GLEnum | None = None, + spans: Any = None) -> 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.dash_cat = dash_cat + 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))] + 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() + 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, dash_period: float = 1.0, + dash_duty: float = 0.5) -> None: + self._pass = ("draw", renderer, mvp, alpha, show_rapids, dash_period, + dash_duty) + + 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 dash and no hidden kind - a highlighted rapid draws solid, and + 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 _use(self, plane: int) -> None: + """Establish the recorded pass's shader state for one plane.""" + if self._pass is None: + return + what, renderer, mvp = self._pass[0], self._pass[1], self._pass[2] + 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.program_array_program() + if what == "override": + program.begin(mvp, palette, dash_cat=-1, hide_cat=-1, + last_drawn_kind=self.last_drawn_kind) + program.set_override_color(self._pass[3]) + return + _w, _r, _m, alpha, show_rapids, dash_period, dash_duty = self._pass + program.begin(mvp, palette, alpha, + dash_cat=self.dash_cat, + hide_cat=-1 if show_rapids else self.hide_cat, + dashed=True, dash_period=dash_period, + dash_duty=dash_duty, + last_drawn_kind=self.last_drawn_kind) + + def draw(self) -> None: + """One draw per plane, each over the whole contiguous vertex range.""" + if not self.count: + return + for plane, vao in enumerate(self.vaos): + self._use(plane) + vao.bind() + glDrawArrays(self.mode, 0, self.count) + vao.unbind() + + def draw_line(self, lineno: int | None) -> None: + """Draw only the spans belonging to source line ``lineno``.""" + if not self.count or self.spans is None or lineno is None: + return + keys, firsts, counts = self.spans + lo = int(np.searchsorted(keys, lineno, side="left")) + hi = int(np.searchsorted(keys, lineno, side="right")) + if lo == hi: + return + for plane, vao in enumerate(self.vaos): + self._use(plane) + 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 buf in self.plane_buffers: + buf.delete() + del self.vaos[:] + del self.plane_buffers[:] + self.attr_buffer.delete() + self.count = 0 + + +class TrajectoryBuffers: + """A buffer in the shared 20-byte vertex format, drawn through the + trajectory shader. + + Holds the vertex buffer, an optional chain table that + ``glMultiDrawArrays`` 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 dashes, and which one 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.dash_cat = -1 + 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, + dash_cat: int = -1, 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.dash_cat = dash_cat + 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, dash_period: float = 1.0, + dash_duty: float = 0.5) -> None: + """Configure the trajectory shader for this buffer's own draw. + + The buffer nominates which of its categories dashes and which the + show-rapids toggle hides, so a buffer nominating neither is unaffected + by either. The shader programs stay shared, hence ``renderer``. + """ + traj = renderer.traj_program() + traj.begin(mvp, self.palette, alpha, + dash_cat=self.dash_cat, + hide_cat=-1 if show_rapids else self.hide_cat, + dashed=True, dash_period=dash_period, + dash_duty=dash_duty) + + 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 dash and no hidden category: a highlighted rapid draws solid, and + # draws even while rapids are hidden. That held before because + # ``u_use_override`` short-circuited the category test; leaving both at + # -1 says it a second way, so the behaviour does not rest on the + # override alone. + traj.begin(mvp, self.palette, dash_cat=-1, 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. + """ + if not self.count: + return + self.vao.bind() + if len(self.firsts): + glMultiDrawArrays(self.mode, self.firsts, self.counts, + len(self.firsts)) + 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 20-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() + 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 begin(self, renderer: GlCanonRenderer, mvp: Any, alpha: float = 1.0, + show_rapids: bool = True, dash_period: float = 1.0, + dash_duty: float = 0.5) -> 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 dash or show-rapids arguments to select: they + are 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() + 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 20-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 # 20-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 dash and no hidden category, so a point + # whose palette index happens to equal the program's rapid code is + # neither dashed nor hidden with rapids off. + renderer.traj_program().begin( + mvp, self.shader_palette or [], alpha, + dash_cat=-1, hide_cat=-1, dashed=False) + 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 = """ +#version 330 core +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 = """ +#version 330 core +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 diff --git a/lib/python/rs274/glcanon_scene.py b/lib/python/rs274/glcanon_scene.py new file mode 100644 index 00000000000..fe9cbba3bff --- /dev/null +++ b/lib/python/rs274/glcanon_scene.py @@ -0,0 +1,1935 @@ +# This is a component of AXIS, a front-end for emc +# Copyright 2004, 2005, 2006 Jeff Epler +# Copyright 2026 Alexey Presniakov <309782758+alex-pres@users.noreply.github.com> +# +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""Composable scene for the G-code preview. + +The preview is drawn by a :class:`Scene` holding an ordered list of small +single-responsibility "part" objects. Everything a part needs is reached +through the per-frame drawing context ``ctx`` it is handed, including the +scoped model-view stack :class:`MatrixStack`. + +A part owns what its drawing needs and nothing decides for it: the GL and +transform state, as one ``scope()``, and - where its geometry lives on the GPU +across frames - the buffer itself, together with every piece of state saying +what is resident in it. The parent owns the gate; the renderer owns only the +resource's lifetime, through ``register()``. Parts that rebuild their geometry +every frame keep no GPU state and hand the renderer a vertex array. + +This module is deliberately free of toolkit (Tk/GTK/Qt) knowledge; the hosting +widget (``rs274.glcanon.GlCanonDraw``) builds the context and runs the scene. +""" + +from __future__ import annotations + +import array +import math +import os +import re +from contextlib import contextmanager +from typing import Any, Callable, Iterator, Sequence + +import numpy as np +import numpy.typing as npt +from OpenGL.GL import (GL_ALWAYS, GL_BLEND, GL_CONSTANT_ALPHA, GL_CULL_FACE, + GL_DEPTH_TEST, GL_FALSE, GL_LEQUAL, + GL_LESS, GL_LINES, GL_LINE_STRIP, GL_ONE, + GL_ONE_MINUS_SRC_ALPHA, GL_SRC_ALPHA, GL_TRIANGLES, + GL_TRUE, glBindVertexArray, glBlendColor, glBlendFunc, + glDepthFunc, glDepthMask, glDisable, glEnable, + glLineWidth, glUseProgram) + +import glnav +import linuxcnc +from rs274 import glcanon_bake, glcanon_gl +from rs274.glcanon_bake import (LineRanges, MeshVerts, TrajectoryVerts, + WideVerts) +from rs274.glcanon_gl import ProgramBuffers, set_line_width + +#: A 4x4 row-major transform, float64 - what glnav builds and the matrix stack +#: multiplies. The shaders take these; nothing here holds a 3x3 except the +#: normal matrix sliced out of one at the call site. +Matrix4 = npt.NDArray[np.float64] + +#: An rgb (or rgba) colour as read out of ``ctx.colors``: 3 or 4 floats in +#: 0..1. Not a fixed-length tuple, because the colour table's own entries are +#: tuples while several parts build one by concatenation. +Color = Sequence[float] + +#: A run of GL_LINES endpoints - pairs of (x, y, z), flat, so an even length. +#: ``Primitives.lines_to_array`` packs these into the wide GPU layout. +LineEndpoints = Sequence[Sequence[float]] + +#: The scene's visibility predicate. The *parent* owns this, never the part: +#: see :class:`Scene`. +Gate = Callable[["FrameContext"], bool] + +#: An axis reordering for the grid: takes an (x, y, z) triple to the plane the +#: current view draws in, and back. ``GridPart`` builds one pair per view. +Permutation = Callable[[Sequence[float]], tuple[float, float, float]] + + +def minmax(*args: float) -> tuple[float, float]: + return min(*args), max(*args) + + +# Axis indices into the 9-axis position/offset tuples +X = 0 +Y = 1 +Z = 2 +A = 3 +B = 4 +C = 5 +U = 6 +V = 7 +W = 8 +R = 9 + +#: GLCANON_SCENE_DEBUG=1 logs the drawn/skipped part split each time it changes +SCENE_DEBUG = bool(os.environ.get("GLCANON_SCENE_DEBUG")) + +# View ports coordinates +VX = 0 +VY = 1 +VZ = 2 +VP = 3 + + +# Home/limit indicator bitmaps drawn beside the DRO lines (13x16, 1bpp). +allhomedicon = array.array('B', + [0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x08, 0x20, + 0x08, 0x20, + 0x08, 0x20, + 0x08, 0x20, + 0x08, 0x20, + 0x0f, 0xe0, + 0x08, 0x20, + 0x08, 0x20, + 0x08, 0x20, + 0x08, 0x20, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00]) + +somelimiticon = array.array('B', + [0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x0f, 0xc0, + 0x08, 0x00, + 0x08, 0x00, + 0x08, 0x00, + 0x08, 0x00, + 0x08, 0x00, + 0x08, 0x00, + 0x08, 0x00, + 0x08, 0x00, + 0x08, 0x00, + 0x00, 0x00, + 0x00, 0x00, + 0x00, 0x00]) + +homeicon = array.array('B', + [0x2, 0x00, 0x02, 0x00, 0x02, 0x00, 0x0f, 0x80, + 0x1e, 0x40, 0x3e, 0x20, 0x3e, 0x20, 0x3e, 0x20, + 0xff, 0xf8, 0x23, 0xe0, 0x23, 0xe0, 0x23, 0xe0, + 0x13, 0xc0, 0x0f, 0x80, 0x02, 0x00, 0x02, 0x00]) + +limiticon = array.array('B', + [ 0, 0, 128, 0, 134, 0, 140, 0, 152, 0, 176, 0, 255, 255, + 255, 255, 176, 0, 152, 0, 140, 0, 134, 0, 128, 0, 0, 0, + 0, 0, 0, 0]) + + + + +class FrameContext: + """Everything a part is allowed to know about the frame it is drawing. + + Built once per frame by the hosting widget. Parts read this and nothing + else - in particular they never reach back to the widget - which is what + makes them testable against a stand-in and keeps the coupling between the + drawing code and the three GUIs that host it an explicit, enumerated list. + + Plain state that every frame reads anyway is resolved eagerly into fields; + anything conditional, expensive, or overridable by a hosting GUI (the DRO + strings, the current tool, the grid hook) stays a callable so it is only + invoked when a part actually needs it and an override still wins. + """ + + __slots__ = ( + # drawing services + 'mv', 'prim', 'renderer', 'colors', + # machine and program state + 'stat', 'canon', 'lp', 'geometry', 'is_lathe', 'is_foam', + 'foam_z', 'foam_w', 'limits', 'joints_mode', + # resolved view flags + 'view', 'width', 'height', 'show_program', 'show_rapids', + 'show_extents', 'show_offsets', 'show_limits', 'show_tool', + 'show_live_plot', 'show_relative', 'show_metric', 'show_small_origin', + 'program_alpha', 'grid_size', 'highlight_line', 'enable_dro', + 'cone_basesize', 'disable_cone_scaling', 'view_tool_min_dia', + # callables: overridable hooks and lazily-needed values + 'preview_mvp', 'to_internal_units', 'to_internal_linear_unit', + 'color_limit', 'draw_grid', 'posstrs', + 'font_info', 'current_tool', 'icon_index', 'show_icon', 'user_plot', + ) + + # Bare annotations, deliberately: an annotation with no value binds no + # class attribute, so ``__slots__`` above keeps working unchanged. Nothing + # below is evaluated at runtime (PEP 563); it is the written form of the + # contract the enumerated list above only names. + + # -- drawing services --------------------------------------------------- + mv: MatrixStack + prim: Primitives + renderer: glcanon_gl.GlCanonRenderer + #: Colour table, ``rs274.glcanon.GlCanonDraw.colors`` resolved for this + #: host. Values are either an rgb 3-tuple of floats in 0..1 or, for the + #: ``*_alpha`` keys, a bare float - hence ``Any`` rather than a union that + #: every read would have to narrow. + colors: dict[str, Any] + + # -- machine and program state ----------------------------------------- + #: ``linuxcnc.stat``. A C extension with no type stubs anywhere in the + #: project; a fabricated type here would be believed and would be wrong. + stat: Any + #: The ``GlCanonDraw`` canon object (``rs274.glcanon.GLCanon`` or a host + #: subclass), or ``None`` before a program is loaded. + canon: Any + #: The position logger, ``emc.positionlogger`` - a C extension, as ``stat``. + lp: Any + #: The GEOMETRY string, e.g. ``"XYZ"`` or ``"-XYZ!"``. Case as the host + #: supplied it; parts upper-case it themselves. + geometry: str + is_lathe: bool + is_foam: bool + foam_z: float + foam_w: float + #: ``(min_xyz, max_xyz)`` soft limits in internal units, three floats each. + limits: tuple[list[float], list[float]] + joints_mode: bool + + # -- resolved view flags ------------------------------------------------ + #: Which view is active, as the 0..3 X/Y/Z/P encoding every host agrees on + #: without any of them declaring it: ``VX``/``VY``/``VZ``/``VP`` in this + #: module, ``x,y,z,p = 0,1,2,3`` at axis.py, and the + #: ``{'x':0,'y':1,'y2':1,'z':2,'z2':2,'p':3}`` dict repeated verbatim in + #: gremlin.py and qt5_graphics.py. This annotation and those constants are + #: the encoding's only written statement. + view: int + width: int + height: int + #: The hosts disagree on the concrete type behind every ``show_*`` flag: + #: gremlin and qt5_graphics hand over Python bools, while AXIS returns + #: ``Tk`` variable ``.get()`` results, which are ints. The contract the + #: parts actually rely on is truthiness, so ``bool`` is what is recorded; + #: no part may compare one of these with ``is True`` or ``==``. + show_program: bool + show_rapids: bool + show_extents: bool + show_offsets: bool + show_limits: bool + show_tool: bool + show_live_plot: bool + show_relative: bool + show_metric: bool + show_small_origin: bool + program_alpha: bool + #: Ground-grid spacing in internal units; ``0`` means "no grid", and is the + #: grid part's visibility gate. + grid_size: float + #: Source line to highlight, or ``None`` for none. AXIS additionally uses + #: non-positive ints for "none", so ``is not None`` is not on its own a + #: sufficient test - the highlight part gates on both. + highlight_line: int | None + enable_dro: bool + cone_basesize: float + disable_cone_scaling: bool + view_tool_min_dia: float + + # -- callables: overridable hooks and lazily-needed values -------------- + # Everything below may be replaced by a hosting GUI (gmoccapy, gscreen, + # QtVCP, hal_gremlin, plasmac2 all replace at least one), so these + # signatures are the contract an override has to satisfy. They were not + # written down anywhere before. + + #: The frame's model-view-projection, a 4x4 float64 numpy matrix. + preview_mvp: Callable[[], npt.NDArray[np.float64]] + #: A 9-axis position tuple converted to internal (display) units. + to_internal_units: Callable[[Sequence[float]], list[float]] + #: One linear value converted to internal units. + to_internal_linear_unit: Callable[[float], float] + #: Passed an out-of-limit predicate, returns it. Kept as a hook because + #: hosts historically overrode it to colour the whole frame. + color_limit: Callable[[Any], Any] + #: Draws the ground grid. plasmac2 replaces this on the instance and calls + #: back into ``GlCanonDraw.draw_grid_permuted``. + draw_grid: Callable[[], None] + #: ``(limit, homed, posstrs, droposstrs)`` - the per-joint limit and homed + #: flags, and the two DRO string lists. hal_gremlin overrides the + #: ``format_dro`` this is built from. + posstrs: Callable[[], tuple[list[int], list[int], list[str], list[str]]] + #: ``(charwidth, linespace, base)``. ``base`` was a GL display-list base in + #: the legacy renderer and is now an opaque glyph-atlas handle + #: (``rs274.glcanon_gl``), so it is ``Any`` on purpose. + font_info: Callable[[], tuple[int, int, Any]] + #: The active tool-table entry, or ``None``. A ``linuxcnc.stat.tool_table`` + #: row - a C-extension object, so ``Any``; parts read ``.diameter`` and the + #: lathe shape fields off it. + current_tool: Callable[[], Any] + #: Passed a DRO line, returns the home/limit icon index, or a negative + #: sentinel for "no icon". See ``GlCanonDraw.idx_for_home_or_limit_icon``. + icon_index: Callable[[str], int] + #: Draws one home/limit icon at ``idx`` from a 13x16 1bpp bitmap. + show_icon: Callable[[int, array.array], None] + #: The host's extra drawing, or ``None`` when it has none - its presence is + #: the part's visibility gate. + user_plot: Callable[[], None] | None + + def __init__(self, **fields): + missing = [n for n in self.__slots__ if n not in fields] + if missing: + raise TypeError("FrameContext missing %s" % ", ".join(missing)) + for name, value in fields.items(): + setattr(self, name, value) + + +class MatrixStack: + """Explicit model-view stack with scoped pushes. + + Replaces the legacy GL matrix stack (removed with the core-profile switch) + and the hand-balanced ``_mv_push``/``_mv_pop`` pairs: a scope opened with + ``with mv.push():`` is restored on exit, including when an exception + unwinds through it, so no part can leak a transform onto a later part. + + ``projection`` is the frame's projection matrix (glnav folds the eye + translation into it); ``mvp()`` folds it with the current top of stack for + the shaders. + """ + + def __init__(self, matrix: Matrix4 | None = None, + projection: Matrix4 | None = None) -> None: + self._stack: list[Matrix4] = [glnav.identity_matrix() if matrix is None + else np.asarray(matrix, dtype=np.float64)] + self.projection = (glnav.identity_matrix() if projection is None + else np.asarray(projection, dtype=np.float64)) + + def reset(self, matrix: Matrix4) -> None: + """Reseed the stack to a single entry (the frame's camera modelview).""" + self._stack = [np.asarray(matrix, dtype=np.float64)] + + def top(self) -> Matrix4: + return self._stack[-1] + + @contextmanager + def push(self) -> Iterator[MatrixStack]: + """Scope the current transform; restored on exit. + + Yields the stack itself, so ``with mv.push() as m:`` binds the same + object ``ctx.mv`` names - the decorator turns this generator into the + context manager parts actually write. + """ + self._stack.append(self._stack[-1].copy()) + depth = len(self._stack) + try: + yield self + finally: + del self._stack[depth - 1:] + + def mult(self, matrix: Matrix4) -> None: + self._stack[-1] = self._stack[-1] @ np.asarray(matrix, dtype=np.float64) + + def translate(self, x: float, y: float, z: float) -> None: + self.mult(glnav.translation_matrix(x, y, z)) + + def rotate(self, angle: float, x: float, y: float, z: float) -> None: + self.mult(glnav.rotation_matrix(angle, x, y, z)) + + def scale(self, x: float, y: float, z: float) -> None: + m = glnav.identity_matrix() + m[0, 0], m[1, 1], m[2, 2] = x, y, z + self.mult(m) + + def mvp(self) -> Matrix4: + """Model-view-projection for the current top of stack.""" + return self.projection @ self._stack[-1] + + # -- unscoped stack ops, for the legacy _mv_push/_mv_pop shims ---------- + def push_unscoped(self) -> None: + self._stack.append(self._stack[-1].copy()) + + def pop_unscoped(self) -> None: + self._stack.pop() + + def __len__(self) -> int: + return len(self._stack) + + +class Primitives: + """Drawing services shared by the parts, reached as ``ctx.prim``. + + These are a tier below the parts: "a line array", "a Hershey string", "the + cone mesh" are things several parts draw, not concerns of their own. They + hold no scene knowledge - everything view- or machine-dependent arrives + through the ``ctx`` passed to each call. + """ + + def __init__(self, hershey: Any = None) -> None: + """``hershey`` is the host's Hershey font table - an + ``rs274.hershey.Hershey``. It is injected by the hosting widget and + never imported here, so it is annotated ``Any`` rather than through a + ``TYPE_CHECKING`` import: a name that resolves only to a type checker + would fail the annotation guard in tests/glcanon-typing. + """ + self.hershey = hershey + self._cone_verts: MeshVerts | None = None + + # -- pure packing ------------------------------------------------------ + @staticmethod + def lines_to_array(points: LineEndpoints, color: Color, + alpha: float = 1.0) -> WideVerts: + """Pack a flat list of (x,y,z) GL_LINES endpoints into an (N,9) array.""" + n = len(points) + arr = np.zeros((n, glcanon_bake.FLOATS_PER_VERTEX), dtype=np.float32) + if n: + arr[:, 0:3] = points + arr[:, 3] = color[0] + arr[:, 4] = color[1] + arr[:, 5] = color[2] + arr[:, 6] = alpha + return arr + + @staticmethod + def limit_color(colors: dict[str, Any], cond: Any) -> Color: + """The label colour legacy color_limit would set (red past a limit).""" + return colors['label_limit'] if cond else colors['label_ok'] + + def cone_mesh(self) -> MeshVerts: + """The tool-cone mesh, built once and reused for every frame.""" + if self._cone_verts is None: + self._cone_verts = glcanon_bake.cone_mesh() + return self._cone_verts + + # -- drawing ----------------------------------------------------------- + @staticmethod + def _unbind() -> None: + glUseProgram(0) + glBindVertexArray(0) + + def draw_lines(self, ctx: FrameContext, points: LineEndpoints, + color: Color, alpha: float = 1.0, + mvp: Matrix4 | None = None) -> None: + """Draw GL_LINES endpoints at the current model-view stack transform.""" + if not len(points): + return + ctx.renderer.draw_line_array( + ctx.mv.mvp() if mvp is None else mvp, + self.lines_to_array(points, color, alpha)) + self._unbind() + + def draw_cube(self, ctx: FrameContext, min_extents: Sequence[float], + max_extents: Sequence[float], + color: Color = (1, 1, 1)) -> None: + """Draw a wireframe box between two X/Y/Z corners.""" + x0, y0, z0 = min_extents[X], min_extents[Y], min_extents[Z] + x1, y1, z1 = max_extents[X], max_extents[Y], max_extents[Z] + self.draw_lines(ctx, [ + # bottom + (x0, y0, z0), (x1, y0, z0), (x1, y0, z0), (x1, y1, z0), + (x1, y1, z0), (x0, y1, z0), (x0, y1, z0), (x0, y0, z0), + # top + (x0, y0, z1), (x1, y0, z1), (x1, y0, z1), (x1, y1, z1), + (x1, y1, z1), (x0, y1, z1), (x0, y1, z1), (x0, y0, z1), + # verticals + (x0, y0, z0), (x0, y0, z1), (x1, y0, z0), (x1, y0, z1), + (x1, y1, z0), (x1, y1, z1), (x0, y1, z0), (x0, y1, z1), + ], color) + + def draw_hershey(self, ctx: FrameContext, s: str, color: Color, + frac: float = 0.0, bbox: bool = False) -> None: + """Draw a Hershey string at the current matrix-stack transform. + + The caller positions/scales/rotates the text via the matrix stack (as + for legacy plot_string); this reads the model-view for the readability + flip, expands the glyph polylines to GL_LINES, and draws them via the + line shader using the folded stack MVP. ``bbox`` adds the out-of-limit + rectangle plot_string draws around the number. + """ + if not s: + return + mv = ctx.mv.top() + polylines = self.hershey.string_polylines( + s, frac, flip_y=mv[2][2] < -0.001, flip_z=mv[1][1] < -0.001, + bbox=bbox) + pts = [] + for poly in polylines: + for i in range(len(poly) - 1): + pts.append((poly[i][0], poly[i][1], 0.0)) + pts.append((poly[i + 1][0], poly[i + 1][1], 0.0)) + self.draw_lines(ctx, pts, color) + + def draw_cone(self, ctx: FrameContext, color: Color, + mesh_verts: MeshVerts | None = None) -> None: + """Draw the tool cone/cylinder through the Lambert cone shader at the + current model-view stack transform (position/rotation/scale already + applied). Eye-space normals come from the stack's 3x3, so the blend + state the caller set still applies. ``mesh_verts`` overrides the cone + mesh (e.g. a cylinder for a large-diameter tool).""" + mv = ctx.mv.top() + ctx.renderer.draw_cone( + ctx.mv.mvp(), mv[:3, :3], tuple(color) + (1.0,), + mesh_verts=self.cone_mesh() if mesh_verts is None else mesh_verts, + light_dir=(1.0, -1.0, 1.0), + ambient=ctx.colors['tool_ambient'], + diffuse=ctx.colors['tool_diffuse']) + self._unbind() + + +class Part: + """One drawing concern, self-contained. + + A part owns the GL and model-view state its drawing needs, as one + :meth:`scope`. It does NOT own the question of whether it is drawn: the + gate lives on the :class:`Scene`'s parts list, so ``draw()`` may assume + the part is participating and MUST NOT open with a + ``if not shown: return`` guard. Branching on live machine or program data + - a zero-length offset, a trail with fewer than two points - is not a + visibility gate and stays inside ``draw()``. + """ + + @contextmanager + def scope(self, ctx: FrameContext) -> Iterator[None]: + """Enter every GL-state and model-view change this part needs, and + leave them on exit - including when an exception unwinds through it. + + The baseline the Scene establishes once per frame IS this no-op + default; only a part needing something else overrides it.""" + yield + + def draw(self, ctx: FrameContext) -> None: + raise NotImplementedError + + def invalidate(self) -> None: + """Drop any GPU-side cache this part owns. Most parts own none.""" + + +class Scene: + """Ordered collection of (part, gate) pairs, run once per frame. + + The gate is a ``ctx -> bool`` predicate the *scene* owns - the parent + deciding whether its child participates, not the child deciding for + itself. A part with no interesting gate uses ``lambda ctx: True``. + """ + + def __init__(self, parts: Sequence[tuple[Part, Gate]] = ()) -> None: + self.parts: list[tuple[Part, Gate]] = list(parts) + self._logged: tuple[tuple[str, ...], tuple[str, ...]] | None = None + + def add(self, part: Part, gate: Gate = lambda ctx: True) -> Part: + self.parts.append((part, gate)) + return part + + def apply_baseline(self) -> None: + """Set the frame's baseline depth and blend state, once per frame. + + Most parts want exactly this and so have an empty ``scope()``; a part + wanting something else sets it in its scope and puts this back. + + Blending stays ON: the baked program colours carry a per-category + alpha (traverse 1/3, arcs 1/2), so they are meant to be composited. + Opaque geometry with alpha 1 is unaffected by it, and disabling blend + here is NOT pixel-neutral. + """ + glEnable(GL_DEPTH_TEST) + glDepthFunc(GL_LESS) + glDepthMask(GL_TRUE) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + + def draw(self, ctx: FrameContext) -> None: + debug = SCENE_DEBUG + drawn, gated_out = [], [] + self.apply_baseline() + for part, gate in self.parts: + if not gate(ctx): + if debug: + gated_out.append(type(part).__name__) + continue + with part.scope(ctx): + part.draw(ctx) + if debug: + drawn.append(type(part).__name__) + if debug: + self._log(drawn, gated_out) + + def _log(self, drawn: list[str], gated_out: list[str]) -> None: + """GLCANON_SCENE_DEBUG=1: report the gated-in set, so "a hidden part is + never entered" is checkable at runtime. Logged when the set changes, + not once per frame.""" + import sys + state = (tuple(drawn), tuple(gated_out)) + if state == self._logged: + return + self._logged = state + print("glcanon scene: drew %s | gated out %s" + % (", ".join(drawn) or "-", ", ".join(gated_out) or "-"), + file=sys.stderr) + + def invalidate(self) -> None: + for part in self: + part.invalidate() + + def __iter__(self) -> Iterator[Part]: + """Yield the parts themselves, in order - not the (part, gate) pairs. + ``build_scene()``'s documented "a GUI may supply a different set or + order of parts" contract, and every consumer that just wants the + parts (invalidate, the debug tests), read this rather than the gates. + """ + return (part for part, _gate in self.parts) + + +# --------------------------------------------------------------------------- +# The parts +# +# One class per drawing concern, in the order the scene runs them. A part's +# draw() assumes it is participating - the gate lives on the scene's parts +# list - and every GL/transform change it needs lives in its scope(). +# --------------------------------------------------------------------------- + + +class GridPart(Part): + """The ground grid. + + ``draw`` goes through ``ctx.draw_grid()`` rather than calling + :meth:`draw_default` directly: ``draw_grid`` is a documented override point + (plasmac2 replaces it wholesale, and calls back into ``draw_permuted``), so + a replacement must still win. + """ + + @contextmanager + def scope(self, ctx: FrameContext) -> Iterator[None]: + """No depth writes: the grid must not occlude program geometry lying + in its plane. A caller reaching ``draw_permuted`` directly (plasmac2's + grid override) must enter this scope itself - see + ``GlCanonDraw.draw_grid_permuted``.""" + with super().scope(ctx): + glDepthMask(GL_FALSE) + try: + yield + finally: + glDepthMask(GL_TRUE) + + def draw(self, ctx: FrameContext) -> None: + ctx.draw_grid() + + def draw_default(self, ctx: FrameContext) -> None: + view = ctx.view + rotation = math.radians(ctx.stat.rotation_xy % 90) + + # perspective view (code stolen from the QtPlasmac crew) + if view == VP: + def permutation(x_y_z2): + return x_y_z2[0], x_y_z2[1], x_y_z2[2] # XY Z + def inverse_permutation(x_y_z3): + return x_y_z3[0], x_y_z3[1], x_y_z3[2] # XY Z + self.draw_permuted(ctx, rotation, permutation, inverse_permutation) + + # all other views + else: + permutations = [ + lambda x_y_z: (x_y_z[2], x_y_z[1], x_y_z[0]), # YZ X + lambda x_y_z1: (x_y_z1[2], x_y_z1[0], x_y_z1[1]), # ZX Y + lambda x_y_z2: (x_y_z2[0], x_y_z2[1], x_y_z2[2]), # XY Z + ] + inverse_permutations = [ + lambda z_y_x: (z_y_x[2], z_y_x[1], z_y_x[0]), # YZ X + lambda z_x_y: (z_x_y[1], z_x_y[2], z_x_y[0]), # ZX Y + lambda x_y_z3: (x_y_z3[0], x_y_z3[1], x_y_z3[2]), # XY Z + ] + self.draw_permuted(ctx, rotation, permutations[view], + inverse_permutations[view]) + + def draw_permuted(self, ctx: FrameContext, rotation: float, + permutation: Permutation, + inverse_permutation: Permutation) -> None: + # The scene skips a zero grid size before dispatching; the check stays + # here because draw_grid is an override point outside callers reach. + grid_size = ctx.grid_size + if not grid_size: return + + s = ctx.stat + tlo_offset = permutation(ctx.to_internal_units(s.tool_offset)[:3]) + g5x_offset = permutation(ctx.to_internal_units(s.g5x_offset)[:3])[:2] + g92_offset = permutation(ctx.to_internal_units(s.g92_offset)[:3])[:2] + + # Rebound twice below, from lists to tuples; the declaration says + # what the whole run of them has in common. + lim_min: Sequence[float] + lim_max: Sequence[float] + lim_min, lim_max = ctx.limits + lim_min = permutation(lim_min) + lim_max = permutation(lim_max) + + lim_min = tuple(a-b for a,b in zip(lim_min, tlo_offset)) + lim_max = tuple(a-b for a,b in zip(lim_max, tlo_offset)) + + if ctx.show_relative: + cos_rot = math.cos(rotation) + sin_rot = math.sin(rotation) + offset = ( + g5x_offset[0] + g92_offset[0] * cos_rot + - g92_offset[1] * sin_rot, + g5x_offset[1] + g92_offset[0] * sin_rot + + g92_offset[1] * cos_rot) + else: + offset = 0., 0. + cos_rot = 1. + sin_rot = 0. + verts: list[Sequence[float]] = [] + self._lines(grid_size, offset, (cos_rot, sin_rot), + lim_min, lim_max, inverse_permutation, verts) + self._lines(grid_size, offset, (sin_rot, -cos_rot), + lim_min, lim_max, inverse_permutation, verts) + if verts: + ctx.prim.draw_lines(ctx, verts, ctx.colors['grid'], + mvp=ctx.preview_mvp()) + + @staticmethod + def _comp(sx_sy: tuple[float, float], + cx_cy: tuple[float, float]) -> float: + (sx, sy) = sx_sy + (cx, cy) = cx_cy + return -(sx*cx + sy*cy) / (sx*sx + sy*sy) + + @staticmethod + def _param(x1_y1: Sequence[float], dx1_dy1: Sequence[float], + x3_y3: Sequence[float], dx3_dy3: Sequence[float]) -> float: + (x1, y1) = x1_y1 + (dx1, dy1) = dx1_dy1 + (x3, y3) = x3_y3 + (dx3, dy3) = dx3_dy3 + den = (dy3)*(dx1) - (dx3)*(dy1) + if den == 0: return 0 + num = (dx3)*(y1-y3) - (dy3)*(x1-x3) + return num * 1. / den + + def _lines(self, space: float, ox_oy: tuple[float, float], + dx_dy: tuple[float, float], lim_min: Sequence[float], + lim_max: Sequence[float], + inverse_permutation: Permutation, + verts: list[Sequence[float]]) -> None: + # collect a series of line segments of the form + # dx(x-ox) + dy(y-oy) + k*space = 0 + # for integers k that intersect the AABB [lim_min, lim_max] + (ox, oy) = ox_oy + (dx, dy) = dx_dy + lim_pts = [ + (lim_min[0], lim_min[1]), + (lim_max[0], lim_min[1]), + (lim_min[0], lim_max[1]), + (lim_max[0], lim_max[1])] + od = self._comp((dy, -dx), (ox, oy)) + d0, d1 = minmax(*(self._comp((dy, -dx), i)-od for i in lim_pts)) + k0 = int(math.ceil(d0/space)) + k1 = int(math.floor(d1/space)) + delta = (dx, dy) + for k in range(k0, k1+1): + d = k*space + # Now we're drawing the line dx(x-ox) + dx(y-oy) + d = 0 + p0 = (ox - dy * d, oy + dx * d) + # which is the same as the line p0 + u * delta + + # but we only want the part that's inside the box lim_pts... + if dx and dy: + times = [ + self._param(p0, delta, lim_min[:2], (0, 1)), + self._param(p0, delta, lim_min[:2], (1, 0)), + self._param(p0, delta, lim_max[:2], (0, 1)), + self._param(p0, delta, lim_max[:2], (1, 0))] + times.sort() + t0, t1 = times[1], times[2] # Take the middle two times + elif dx: + times = [ + self._param(p0, delta, lim_min[:2], (0, 1)), + self._param(p0, delta, lim_max[:2], (0, 1))] + times.sort() + t0, t1 = times[0], times[1] # Take the only two times + else: + times = [ + self._param(p0, delta, lim_min[:2], (1, 0)), + self._param(p0, delta, lim_max[:2], (1, 0))] + times.sort() + t0, t1 = times[0], times[1] # Take the only two times + x0, y0 = p0[0] + delta[0]*t0, p0[1] + delta[1]*t0 + x1, y1 = p0[0] + delta[0]*t1, p0[1] + delta[1]*t1 + + verts.append(inverse_permutation((x0, y0, lim_min[2]))) + verts.append(inverse_permutation((x1, y1, lim_min[2]))) + + +class ProgramResource: + """The program's GPU buffers, and the policy for invalidating them. + + The drawing part, the highlight part and the :class:`Picker` all hold a + reference to one of these, so what is pickable is exactly what is drawn + and a reload rebuilds for all three at once. The :class:`ProgramBuffers` + is owned here and registered with the renderer, so one ``delete()`` still + releases every GL object on context loss. + + It holds no program geometry of its own. The CPU arrays - the vertices, + the dwell and tool tables, the extents, the line index - belong to the + canon, which fills them during the parse; this uploads them. The two have + different owners because they have different lifetimes: a canon parsed + with no GL context has a complete program record, and ``_stale`` is scene + policy driven by ``set_canon``/``stale_dlist``, while the buffers are pure + GL state. + """ + + def __init__(self) -> None: + self._stale = True + self.buffers: ProgramBuffers | None = None + + @property + def stale(self) -> bool: + return self._stale + + def invalidate(self) -> None: + self._stale = True + + def geometry(self, ctx: FrameContext) -> Any: + """The canon's program record, or ``None`` if there is no canon.""" + if ctx.canon is None: + return None + return ctx.canon.program_geometry + + def ensure_uploaded(self, ctx: FrameContext) -> None: + if self._stale: + self.upload(ctx) + + def upload(self, ctx: FrameContext) -> None: + """Upload the canon's program record into this resource's buffers.""" + if self.buffers is None: + # Created on first upload, when there is a context: registering + # makes the renderer release it without owning how it is drawn. + self.buffers = ctx.renderer.register(ProgramBuffers()) + geometry = self.geometry(ctx) + if geometry is None: + self.buffers.upload([]) + else: + self.buffers.upload(glcanon_bake.program_parts( + geometry, ctx.colors, is_foam=ctx.is_foam, + foam_z=ctx.foam_z, foam_w=ctx.foam_w, + is_lathe=ctx.is_lathe)) + self._stale = False + + +@contextmanager +def program_alpha(ctx: FrameContext) -> Iterator[None]: + """Composite the program translucently when the toggle wants it: no depth + test, plain source-alpha blend. + + A property of how the program sits in the rest of the scene, not of its own + geometry - which is why both parts drawn from the program's buffers need + it. Shared as a context manager the scopes compose with ``with``, rather + than as a base class: composition, not inheritance. + """ + if not ctx.program_alpha: + yield + return + glDisable(GL_DEPTH_TEST) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + try: + yield + finally: + glDisable(GL_BLEND) + glEnable(GL_DEPTH_TEST) + + +class ProgramPart(Part): + """The program's trajectory: traverse, feed and arc moves. + + Rapids are dashed in the shader, at a period scaled to the program so the + dash count stays roughly view-independent. + """ + + def __init__(self, resource: ProgramResource | None = None) -> None: + self.resource = resource if resource is not None else ProgramResource() + + @contextmanager + def scope(self, ctx: FrameContext) -> Iterator[None]: + with super().scope(ctx), program_alpha(ctx): + try: + yield + finally: + glUseProgram(0) + glBindVertexArray(0) + + def invalidate(self) -> None: + self.resource.invalidate() + + def draw(self, ctx: FrameContext) -> None: + self.resource.ensure_uploaded(ctx) + self.resource.buffers.draw(ctx.renderer, ctx.preview_mvp(), + show_rapids=ctx.show_rapids, alpha=1.0, + dash_period=self.dash_period(ctx)) + + @staticmethod + def dash_period(ctx: FrameContext) -> float: + """World-space dash period giving a roughly view-independent dash count.""" + if ctx.canon is not None: + span = max((a - b) for a, b in + zip(ctx.canon.max_extents, ctx.canon.min_extents)) + if span > 0: + return span / 60.0 + return 0.5 + + +class HighlightPart(Part): + """The selected line, redrawn thicker over the program it belongs to. + + The same GPU geometry as :class:`ProgramPart`, drawn a second time with a + different colour, depth function and line width - which is why it is a part + rather than a second draw inside that one. It shares the + :class:`ProgramResource` instance exactly as :class:`Picker` does, so the + line it highlights is always a line that was drawn, and the spans it + redraws come from the canon's own line index rather than from a second + table built beside the vertices. + + ``GL_LEQUAL`` covers this draw and no other. Widening it to the program's + own draw would let coincident program segments win the depth tie instead of + being rejected, which is a visible change; that is the reason this is a + separate scope rather than state flipped part-way through one. + """ + + def __init__(self, resource: ProgramResource | None = None) -> None: + self.resource = resource if resource is not None else ProgramResource() + + @contextmanager + def scope(self, ctx: FrameContext) -> Iterator[None]: + """The program's alpha compositing, plus the depth tie-break and the + width that make the highlight sit over the geometry it duplicates.""" + with super().scope(ctx), program_alpha(ctx): + set_line_width(3.0) + glDepthFunc(GL_LEQUAL) + try: + yield + finally: + glDepthFunc(GL_LESS) + set_line_width(1.0) + glUseProgram(0) + glBindVertexArray(0) + + def invalidate(self) -> None: + self.resource.invalidate() + + def draw(self, ctx: FrameContext) -> None: + self.resource.ensure_uploaded(ctx) + self.resource.buffers.draw_line( + ctx.renderer, ctx.preview_mvp(), ctx.highlight_line, + tuple(ctx.colors['selected']) + (1.0,)) + + +class Picker: + """Click-to-select: which program line is under the cursor. + + Not a scene part - it draws nothing to the screen. It renders the same + program geometry the scene draws into an offscreen framebuffer with each + segment's source line number encoded as colour, reads the patch under the + cursor and resolves the nearest hit by depth. Sharing + :class:`ProgramResource` with :class:`ProgramPart` is what keeps the + pickable geometry from drifting from the drawn geometry - including + honouring show-rapids the same way, and rejecting record kinds the same + way. + """ + + def __init__(self, resource: ProgramResource) -> None: + self.resource = resource + + def pick(self, ctx: FrameContext, x_view: int, + y_view: int) -> int | None: + """Acquire the offscreen target, draw the ids into it, resolve. + + The read-back happens inside the target's block: ``glReadPixels`` takes + whatever framebuffer is bound, so resolving after the restore would + read the visible frame instead. + """ + if ctx.canon is None: + return None + width, height = int(ctx.width), int(ctx.height) + if width < 5 or height < 5: + return None + self.resource.ensure_uploaded(ctx) + buffers = self.resource.buffers + if not buffers.buffers: + return None + target = ctx.renderer.pick_target(width, height) + with target.offscreen(): + buffers.draw_ids(ctx.renderer, ctx.preview_mvp(), ctx.show_rapids) + return target.resolve(x_view, y_view) + + +class ExtentsPart(Part): + """Program dimension lines and their Hershey labels. + + Labels past a machine soft limit are drawn in the limit colour with the + out-of-limit box around them. + """ + + def draw(self, ctx: FrameContext) -> None: + s = ctx.stat + g = ctx.canon + + # Dimensions + view = ctx.view + is_metric = ctx.show_metric + dimscale = is_metric and 25.4 or 1.0 + fmt = is_metric and "%.1f" or "%.2f" + + machine_limit_min, machine_limit_max = ctx.limits + + pullback = max(g.max_extents[X] - g.min_extents[X], + g.max_extents[Y] - g.min_extents[Y], + g.max_extents[Z] - g.min_extents[Z], + 2) * .1 + + dashwidth = pullback/4 + charsize = dashwidth * 1.5 + halfchar = charsize * .5 + + if view == VZ or view == VP: + z_pos = g.min_extents[VZ] + zdashwidth = 0 + else: + z_pos = g.min_extents[VZ] - pullback + zdashwidth = dashwidth + + #draw dimension lines (label_ok colour, matching legacy color_limit(0)) + dim_verts = [] + + # x dimension + if view != VX and g.max_extents[X] > g.min_extents[X]: + y_pos = g.min_extents[Y] - pullback + dim_verts += [ + (g.min_extents[X], y_pos, z_pos), + (g.max_extents[X], y_pos, z_pos), + (g.min_extents[X], y_pos - dashwidth, z_pos - zdashwidth), + (g.min_extents[X], y_pos + dashwidth, z_pos + zdashwidth), + (g.max_extents[X], y_pos - dashwidth, z_pos - zdashwidth), + (g.max_extents[X], y_pos + dashwidth, z_pos + zdashwidth)] + + # y dimension + if view != VY and g.max_extents[Y] > g.min_extents[Y]: + x_pos = g.min_extents[X] - pullback + dim_verts += [ + (x_pos, g.min_extents[Y], z_pos), + (x_pos, g.max_extents[Y], z_pos), + (x_pos - dashwidth, g.min_extents[Y], z_pos - zdashwidth), + (x_pos + dashwidth, g.min_extents[Y], z_pos + zdashwidth), + (x_pos - dashwidth, g.max_extents[Y], z_pos - zdashwidth), + (x_pos + dashwidth, g.max_extents[Y], z_pos + zdashwidth)] + + # z dimension + if view != VZ and g.max_extents[Z] > g.min_extents[Z]: + x_pos = g.min_extents[X] - pullback + y_pos = g.min_extents[Y] - pullback + dim_verts += [ + (x_pos, y_pos, g.min_extents[Z]), + (x_pos, y_pos, g.max_extents[Z]), + (x_pos - dashwidth, y_pos - zdashwidth, g.min_extents[Z]), + (x_pos + dashwidth, y_pos + zdashwidth, g.min_extents[Z]), + (x_pos - dashwidth, y_pos - zdashwidth, g.max_extents[Z]), + (x_pos + dashwidth, y_pos + zdashwidth, g.max_extents[Z])] + + if dim_verts: + ctx.prim.draw_lines(ctx, dim_verts, ctx.colors['label_ok']) + + # Labels + # get_show_relative == True calculates extents from the local origin + # get_show_relative == False calculates extents from the machine origin + offset: Sequence[float] + if ctx.show_relative: + offset = ctx.to_internal_units(s.g5x_offset + s.g92_offset) + else: + offset = 0, 0, 0 + #Z extent labels + if view != VZ and g.max_extents[Z] > g.min_extents[Z]: + if view == VX: + x_pos = g.min_extents[X] - pullback + y_pos = g.min_extents[Y] - 6.0*dashwidth + else: + x_pos = g.min_extents[X] - 6.0*dashwidth + y_pos = g.min_extents[Y] - pullback + #Z MIN extent + bbox = ctx.color_limit(g.min_extents_notool[Z] < machine_limit_min[Z]) + with ctx.mv.push(): + f = fmt % ((g.min_extents[Z]-offset[Z]) * dimscale) + ctx.mv.translate(x_pos, y_pos, g.min_extents[Z] - halfchar) + ctx.mv.scale(charsize, charsize, charsize) + ctx.mv.rotate(-90, 0, 1, 0) + ctx.mv.rotate(-90, 0, 0, 1) + if view != VX: + ctx.mv.rotate(-90, 0, 1, 0) + ctx.prim.draw_hershey(ctx, f, ctx.prim.limit_color(ctx.colors, bbox), 0, bbox=bbox) + #Z MAX extent + bbox = ctx.color_limit(g.max_extents_notool[Z] > machine_limit_max[Z]) + with ctx.mv.push(): + f = fmt % ((g.max_extents[Z]-offset[Z]) * dimscale) + ctx.mv.translate(x_pos, y_pos, g.max_extents[Z] - halfchar) + ctx.mv.scale(charsize, charsize, charsize) + ctx.mv.rotate(-90, 0, 1, 0) + ctx.mv.rotate(-90, 0, 0, 1) + if view != VX: + ctx.mv.rotate(-90, 0, 1, 0) + ctx.prim.draw_hershey(ctx, f, ctx.prim.limit_color(ctx.colors, bbox), 0, bbox=bbox) + ctx.color_limit(0) + with ctx.mv.push(): + #Z Midpoint + f = fmt % ((g.max_extents[Z] - g.min_extents[Z]) * dimscale) + ctx.mv.translate(x_pos, y_pos, (g.max_extents[Z] + g.min_extents[Z])/2) + ctx.mv.scale(charsize, charsize, charsize) + if view != VX: + ctx.mv.rotate(-90, 0, 0, 1) + ctx.mv.rotate(-90, 0, 1, 0) + ctx.prim.draw_hershey(ctx, f, ctx.colors['label_ok'], .5, bbox=bbox) + #Y extent labels + if view != VY and g.max_extents[Y] > g.min_extents[Y]: + x_pos = g.min_extents[X] - 6.0*dashwidth + #Y MIN extent + bbox = ctx.color_limit(g.min_extents_notool[Y] < machine_limit_min[Y]) + with ctx.mv.push(): + f = fmt % ((g.min_extents[Y] - offset[Y]) * dimscale) + ctx.mv.translate(x_pos, g.min_extents[Y] + halfchar, z_pos) + ctx.mv.rotate(-90, 0, 0, 1) + ctx.mv.rotate(-90, 0, 0, 1) + if view == VX: + ctx.mv.rotate(90, 0, 1, 0) + ctx.mv.translate(dashwidth*1.5, 0, 0) + ctx.mv.scale(charsize, charsize, charsize) + ctx.prim.draw_hershey(ctx, f, ctx.prim.limit_color(ctx.colors, bbox), 0, bbox=bbox) + #Y MAX extent + bbox = ctx.color_limit(g.max_extents_notool[Y] > machine_limit_max[Y]) + with ctx.mv.push(): + f = fmt % ((g.max_extents[Y] - offset[Y]) * dimscale) + ctx.mv.translate(x_pos, g.max_extents[Y] + halfchar, z_pos) + ctx.mv.rotate(-90, 0, 0, 1) + ctx.mv.rotate(-90, 0, 0, 1) + if view == VX: + ctx.mv.rotate(90, 0, 1, 0) + ctx.mv.translate(dashwidth*1.5, 0, 0) + ctx.mv.scale(charsize, charsize, charsize) + ctx.prim.draw_hershey(ctx, f, ctx.prim.limit_color(ctx.colors, bbox), 0, bbox=bbox) + + ctx.color_limit(0) + with ctx.mv.push(): + #Y midpoint + f = fmt % ((g.max_extents[Y] - g.min_extents[Y]) * dimscale) + ctx.mv.translate(x_pos, (g.max_extents[Y] + g.min_extents[Y])/2, + z_pos) + ctx.mv.rotate(-90, 0, 0, 1) + if view == VX: + ctx.mv.rotate(-90, 1, 0, 0) + ctx.mv.translate(0, halfchar, 0) + ctx.mv.scale(charsize, charsize, charsize) + ctx.prim.draw_hershey(ctx, f, ctx.colors['label_ok'], .5) + #X extent labels + if view != VX and g.max_extents[X] > g.min_extents[X]: + y_pos = g.min_extents[Y] - 6.0*dashwidth + #X MIN extent + bbox = ctx.color_limit(g.min_extents_notool[X] < machine_limit_min[X]) + with ctx.mv.push(): + f = fmt % ((g.min_extents[X] - offset[X]) * dimscale) + ctx.mv.translate(g.min_extents[X] - halfchar, y_pos, z_pos) + ctx.mv.rotate(-90, 0, 0, 1) + if view == VY: + ctx.mv.rotate(90, 0, 1, 0) + ctx.mv.translate(dashwidth*1.5, 0, 0) + ctx.mv.scale(charsize, charsize, charsize) + ctx.prim.draw_hershey(ctx, f, ctx.prim.limit_color(ctx.colors, bbox), 0, bbox=bbox) + #X MAX extent + bbox = ctx.color_limit(g.max_extents_notool[X] > machine_limit_max[X]) + with ctx.mv.push(): + f = fmt % ((g.max_extents[X] - offset[X]) * dimscale) + ctx.mv.translate(g.max_extents[X] - halfchar, y_pos, z_pos) + ctx.mv.rotate(-90, 0, 0, 1) + if view == VY: + ctx.mv.rotate(90, 0, 1, 0) + ctx.mv.translate(dashwidth*1.5, 0, 0) + ctx.mv.scale(charsize, charsize, charsize) + ctx.prim.draw_hershey(ctx, f, ctx.prim.limit_color(ctx.colors, bbox), 0, bbox=bbox) + + ctx.color_limit(0) + with ctx.mv.push(): + #X midpoint + f = fmt % ((g.max_extents[X] - g.min_extents[X]) * dimscale) + ctx.mv.translate((g.max_extents[X] + g.min_extents[X])/2, y_pos, + z_pos) + if view == VY: + ctx.mv.rotate(-90, 1, 0, 0) + ctx.mv.translate(0, halfchar, 0) + ctx.mv.scale(charsize, charsize, charsize) + ctx.prim.draw_hershey(ctx, f, ctx.colors['label_ok'], .5) + + +class BoundingBoxPart(Part): + """Plain extents box, drawn in place of the program when it is not shown.""" + + def draw(self, ctx: FrameContext) -> None: + g = ctx.canon + ctx.prim.draw_cube(ctx, g.min_extents, g.max_extents, + color=(0.57, 0.68, 0.71)) + + +class UserPlotPart(Part): + """The third-party user_plot() hook, for GUIs that draw their own overlay. + + The hook is optional, so its presence is the visibility gate; the guard + inside draw is for exceptions raised by the hook itself, which must never + take the rest of the frame down with them. + """ + + def draw(self, ctx: FrameContext) -> None: + try: + ctx.user_plot() + except Exception: + pass + + +class SmallOriginPart(Part): + """The small origin marker - three circles and three crosses - at the + program zero.""" + + RADIUS = 2.0/25.4 + + def draw(self, ctx: FrameContext) -> None: + r = self.RADIUS + verts: list[tuple[float, float, float]] = [] + + def circle(axis: str) -> None: + pts = [] + for i in range(37): + theta = (i*10)*math.pi/180.0 + c, sn = r*math.cos(theta), r*math.sin(theta) + if axis == 'z': + pts.append((c, sn, 0.0)) + elif axis == 'x': + pts.append((0.0, c, sn)) + else: + pts.append((c, 0.0, sn)) + for i in range(len(pts)-1): + verts.append(pts[i]); verts.append(pts[i+1]) + + circle('z'); circle('x'); circle('y') + verts += [(-r, -r, 0.0), (r, r, 0.0), (-r, r, 0.0), (r, -r, 0.0), + (-r, 0.0, -r), (r, 0.0, r), (-r, 0.0, r), (r, 0.0, -r), + (0.0, -r, -r), (0.0, r, r), (0.0, -r, r), (0.0, r, -r)] + ctx.prim.draw_lines(ctx, verts, ctx.colors['small_origin']) + + +class OffsetsPart(Part): + """The g5x and g92 offset vectors and their labels. + + Both are the same drawing - a line from the current origin to the offset, + with the label laid along it - at two points of the enclosing group's + transform build-up, so the group drives them one at a time. + """ + + @staticmethod + def has_offset(offset: Sequence[float]) -> bool: + return bool(offset[X] or offset[Y] or offset[Z]) + + def draw_offset(self, ctx: FrameContext, offset: Sequence[float], + label: str) -> None: + color = ctx.colors['small_origin'] + ctx.prim.draw_lines(ctx, [(0, 0, 0), tuple(offset)], color) + + with ctx.mv.push(): + ctx.mv.scale(0.2, 0.2, 0.2) + if ctx.is_lathe: + rot = math.atan2(offset[X], -offset[Z]) + ctx.mv.rotate(90, 1, 0, 0) + ctx.mv.rotate(-90, 0, 0, 1) + else: + rot = math.atan2(offset[Y], offset[X]) + ctx.mv.rotate(math.degrees(rot), 0, 0, 1) + ctx.mv.translate(0.5, 0.5, 0) + ctx.prim.draw_hershey(ctx, label, color, 0.1) + + @staticmethod + def g5x_label(ctx: FrameContext) -> str: + i = ctx.stat.g5x_index + return "G5%d" % (i+3) if i < 7 else "G59.%d" % (i-6) + + +class AxesPart(Part): + """The three axis segments and their letters (foam draws a second set for + the U/V/W plane).""" + + def draw(self, ctx: FrameContext) -> None: + if ctx.is_foam: + ctx.mv.translate(0, 0, ctx.foam_z) + self.draw_axes(ctx, "XYZ") + ctx.mv.translate(0, 0, ctx.foam_w - ctx.foam_z) + self.draw_axes(ctx, "UVW") + else: + self.draw_axes(ctx, "XYZ") + + def draw_axes(self, ctx: FrameContext, letters: str = "XYZ") -> None: + view = ctx.view + base_mvp = ctx.mv.mvp() + + ctx.prim.draw_lines(ctx, [(1, 0, 0), (0, 0, 0)], + ctx.colors['axis_x'], mvp=base_mvp) + + if view != VX: + with ctx.mv.push(): + if ctx.is_lathe: + ctx.mv.translate(1.3, -0.1, 0) + ctx.mv.translate(0, 0, -0.1) + ctx.mv.rotate(-90, 0, 1, 0) + ctx.mv.rotate(90, 1, 0, 0) + ctx.mv.translate(0.1, 0, 0) + else: + ctx.mv.translate(1.2, -0.1, 0) + if view == VY: + ctx.mv.translate(0, 0, -0.1) + ctx.mv.rotate(90, 1, 0, 0) + ctx.mv.scale(0.2, 0.2, 0.2) + ctx.prim.draw_hershey(ctx, letters[0], ctx.colors['axis_x'], 0.5) + + ctx.prim.draw_lines(ctx, [(0, 0, 0), (0, 1, 0)], + ctx.colors['axis_y'], mvp=base_mvp) + + if view != VY: + with ctx.mv.push(): + ctx.mv.translate(0, 1.2, 0) + if view == VX: + ctx.mv.translate(0, 0, -0.1) + ctx.mv.rotate(90, 0, 1, 0) + ctx.mv.rotate(90, 0, 0, 1) + ctx.mv.scale(0.2, 0.2, 0.2) + ctx.prim.draw_hershey(ctx, letters[1], ctx.colors['axis_y'], 0.5) + + ctx.prim.draw_lines(ctx, [(0, 0, 0), (0, 0, 1)], + ctx.colors['axis_z'], mvp=base_mvp) + + if view != VZ: + with ctx.mv.push(): + ctx.mv.translate(0, 0, 1.2) + if ctx.is_lathe: + ctx.mv.rotate(-90, 0, 1, 0) + if view == VX: + ctx.mv.rotate(90, 0, 1, 0) + ctx.mv.rotate(90, 0, 0, 1) + elif view == VY or view == VP: + ctx.mv.rotate(90, 1, 0, 0) + if ctx.is_lathe: + ctx.mv.translate(0, -.1, 0) + ctx.mv.scale(0.2, 0.2, 0.2) + ctx.prim.draw_hershey(ctx, letters[2], ctx.colors['axis_z'], 0.5) + + +class RelativeCoordPart(Part): + """Offsets, small origin and axes, which share one transform. + + They are not three independent parts: inside a single model-view scope the + g5x offset, the XY rotation and then the g92 offset are applied in turn, so + each element is drawn in the frame the ones before it established, and the + axes end up on the program origin rather than the machine origin. + Rebuilding that transform per part would duplicate it three times and + invite drift, so this part owns it and draws its three sub-drawings in + place. + + Being their parent, it owns their gates too - which is the rule applied + one level down, not an exception to it. The three stay named attributes + because ``glcanon.py`` reaches ``small_origin`` and ``axes`` by name. + """ + + def __init__(self) -> None: + self.small_origin = SmallOriginPart() + self.offsets = OffsetsPart() + self.axes = AxesPart() + + def invalidate(self) -> None: + for part in (self.small_origin, self.offsets, self.axes): + part.invalidate() + + @staticmethod + def _offset_frame(ctx: FrameContext) -> Any: + """Whether there is an offset frame to build at all. + + Returns whatever the first truthy offset component is rather than a + bool - the caller only ever tests it, and narrowing it to ``bool`` + here would be a behaviour change. + """ + s = ctx.stat + return ctx.show_relative and ( + s.g5x_offset[X] or s.g5x_offset[Y] or s.g5x_offset[Z] or + s.g92_offset[X] or s.g92_offset[Y] or s.g92_offset[Z] or + s.rotation_xy) + + def draw(self, ctx: FrameContext) -> None: + s = ctx.stat + with ctx.mv.push(): + if self._offset_frame(ctx): + # This part is the parent of small_origin/offsets/axes, so it + # owns their gates directly - the same rule the scene follows + # for its own parts, one level down. + if ctx.show_small_origin: + self.small_origin.draw(ctx) + + g5x_offset = ctx.to_internal_units(s.g5x_offset)[:3] + g92_offset = ctx.to_internal_units(s.g92_offset)[:3] + show_offsets = ctx.show_offsets + + if show_offsets and self.offsets.has_offset(g5x_offset): + self.offsets.draw_offset(ctx, g5x_offset, + self.offsets.g5x_label(ctx)) + + ctx.mv.translate(*g5x_offset) + ctx.mv.rotate(s.rotation_xy, 0, 0, 1) + + if show_offsets and self.offsets.has_offset(g92_offset): + self.offsets.draw_offset(ctx, g92_offset, "G92") + + ctx.mv.translate(*g92_offset) + + self.axes.draw(ctx) + + +class LimitsBoxPart(Part): + """The machine soft-limit cube. + + Drawn in the frame the tool offset is measured from, so the box stays put + when a tool length offset shifts the program. + """ + + @contextmanager + def scope(self, ctx: FrameContext) -> Iterator[None]: + """Drawn in the frame the tool offset is measured from, so the box + stays put when a tool length offset shifts the program.""" + with super().scope(ctx), ctx.mv.push(): + tool_offset = ctx.to_internal_units(ctx.stat.tool_offset)[:3] + ctx.mv.translate(*[-pos for pos in tool_offset]) + glLineWidth(1) + yield + + def draw(self, ctx: FrameContext) -> None: + machine_limit_min, machine_limit_max = ctx.limits + ctx.prim.draw_cube(ctx, machine_limit_min, machine_limit_max, + color=ctx.colors['limits']) + + +class BackplotPart(Part): + """The live motion trail, from the position logger's ring buffer. + + Owns the incremental-upload bookkeeping: only the changed tail of the + logger's points is sent each frame. ``invalidate()`` forces the next frame + to re-send everything. + """ + + @contextmanager + def scope(self, ctx: FrameContext) -> Iterator[None]: + """Drawn over world geometry at equal depth (LEQUAL, not the baseline + LESS), in the logger's machine units scaled to the internal unit the + rest of the scene draws in.""" + with super().scope(ctx), ctx.mv.push(): + glDepthFunc(GL_LEQUAL) + set_line_width(3.0) + lu = 1/((ctx.stat.linear_units or 1)*25.4) + ctx.mv.scale(lu, lu, lu) + try: + yield + finally: + glUseProgram(0) + glBindVertexArray(0) + set_line_width(1.0) + glDepthFunc(GL_LESS) + + def __init__(self) -> None: + #: BackplotRing, created on the first frame + self._ring: glcanon_gl.BackplotRing | None = None + + def ring(self, ctx: FrameContext) -> glcanon_gl.BackplotRing: + """This part's ring buffer, created once there is a context to make it + in and registered so the renderer still releases it. + + The palette is handed in rather than made by the ring, so the GL module + keeps its independence from the baking module. It is append-only across + the session - see :class:`glcanon_bake.ColorPalette`. + """ + if self._ring is None: + self._ring = ctx.renderer.register( + glcanon_gl.BackplotRing(glcanon_bake.ColorPalette())) + return self._ring + + def invalidate(self) -> None: + if self._ring is not None: + self._ring.invalidate() + + def draw(self, ctx: FrameContext) -> None: + self.upload_and_draw(ctx) + + @staticmethod + def mvp(ctx: FrameContext) -> Matrix4: + """MVP for the live backplot: the current (unit-scaled) model-view stack + plus the small +0.003 eye-space z bias the legacy path applied on the + projection stack so the plot wins the depth tie with the program lines. + """ + bias = glnav.identity_matrix() + bias[2, 3] = 0.003 + return ctx.mv.projection @ bias @ ctx.mv.top() + + def upload_and_draw(self, ctx: FrameContext) -> None: + """Upload the logger's points to the ring VBO and draw the backplot. + + Reads a private copy of the point buffer via positionlogger.points(), + converts only the points the ring says are not already resident, and + writes that tail. Replaces the immediate-mode positionlogger.call + in-tree. + + The ring answers its own residency question - asked before converting, + so a frame needing the whole trail is never met with a tail. There is + no offset to relay and nothing to police: the object that gave out the + offset is the one that acts on it. + """ + raw, npts, is_xyuv = ctx.lp.points() + ring = self.ring(ctx) + if npts < 2: + ring.invalidate() + return + first_point = ring.resident_points(npts, is_xyuv) + verts = glcanon_bake.backplot_vertices(raw, npts, is_xyuv, + first_point=first_point, + palette=ring.palette) + if verts.shape[1] != glcanon_bake.TRAJ_FLOATS_PER_VERTEX: + # The palette ran out mid-tail. That widens every vertex, not just + # the tail's, so there is no buffer for a tail to go into: convert + # the whole trail in the per-vertex-colour layout instead. + verts = glcanon_bake.backplot_vertices(raw, npts, is_xyuv) + first_point = 0 + ring.write(verts, first_point, is_xyuv) + ring.draw(ctx.renderer, self.mvp(ctx)) + + +class ToolPart(Part): + """The tool marker at the current machine position. + + One concern, four shapes: the plain cone, the foam cutter's pair of cones + (one per wire end), a lathe tool's profile, and a Lambert-shaded cylinder + for a tool wider than GCODE_VIEW_TOOL_MIN_DIA. The odd blend and cull state + below is what the legacy immediate-mode tool set, kept for pixel parity. + """ + + #: (dx, dy) of the tool tip for each lathe tool orientation, 1..9 + LATHE_SHAPES = [ + None, # 0 + (1,-1), (1,1), (-1,1), (-1,-1), # 1..4 + (0,-1), (1,0), (0,1), (-1,0), # 5..8 + (0,0) # 9 + ] + + @contextmanager + def scope(self, ctx: FrameContext) -> Iterator[None]: + """A matrix scope for the marker's position, plus - for the + non-foam shapes only - the odd GL_ONE/GL_CONSTANT_ALPHA blend and face + culling the legacy immediate-mode tool marker used, kept for pixel + parity.""" + with super().scope(ctx), ctx.mv.push(): + if ctx.is_foam: + yield + return + glEnable(GL_BLEND) + glEnable(GL_CULL_FACE) + glBlendFunc(GL_ONE, GL_CONSTANT_ALPHA) + try: + yield + finally: + glDisable(GL_CULL_FACE) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + + def draw(self, ctx: FrameContext) -> None: + pos = ctx.lp.last(ctx.show_live_plot) + if pos is None: pos = [0] * 6 + rx, ry, rz = pos[3:6] + pos = ctx.to_internal_units(pos[:3]) + if ctx.is_foam: + self._draw_foam(ctx, pos, rx, ry) + else: + self._draw_at_tool(ctx, pos, rx, ry, rz) + + def _draw_foam(self, ctx: FrameContext, pos: Sequence[float], + rx: float, ry: float) -> None: + """A foam cutter has two cutting points - one on each wire end - so it + draws a cone in the XY plane and a second in the UV plane.""" + with ctx.mv.push(): + ctx.mv.translate(pos[0], pos[1], ctx.foam_z) + ctx.mv.rotate(180, 1, 0, 0) + ctx.prim.draw_cone(ctx, ctx.colors['cone_xy']) + u = ctx.to_internal_linear_unit(rx) + v = ctx.to_internal_linear_unit(ry) + with ctx.mv.push(): + ctx.mv.translate(u, v, ctx.foam_w) + ctx.prim.draw_cone(ctx, ctx.colors['cone_uv']) + + def _draw_at_tool(self, ctx: FrameContext, pos: Sequence[float], + rx: float, ry: float, rz: float) -> None: + ctx.mv.translate(*pos) + self._apply_rotary(ctx, rx, ry, rz) + current_tool = ctx.current_tool() + if current_tool is None or current_tool.diameter <= ctx.view_tool_min_dia: + self._draw_cone(ctx) + else: + self.draw_solid(ctx, current_tool) + + @staticmethod + def _apply_rotary(ctx: FrameContext, rx: float, ry: float, + rz: float) -> None: + """Tilt the marker by the rotary axes, in GEOMETRY order.""" + sign = 1 + # Reversed back into GEOMETRY order below; the loop walks characters. + g: str = "".join(reversed(re.split(" *(-?[XYZABCUVW])", ctx.geometry))) + + for ch in g: # Apply in original non-reversed GEOMETRY order + if ch == '-': + sign = -1 + elif ch == 'A': + ctx.mv.rotate(rx*sign, 1, 0, 0) + sign = 1 + elif ch == 'B': + ctx.mv.rotate(ry*sign, 0, 1, 0) + sign = 1 + elif ch == 'C': + ctx.mv.rotate(rz*sign, 0, 0, 1) + sign = 1 + else: + sign = 1 # reset sign for non-rotational axis "XYZUVW" + + @staticmethod + def _draw_cone(ctx: FrameContext) -> None: + """The default marker, scaled to the program so it stays legible.""" + if ctx.canon and not ctx.disable_cone_scaling: + g = ctx.canon + + cone_scale = max(g.max_extents[X] - g.min_extents[X], + g.max_extents[Y] - g.min_extents[Y], + g.max_extents[Z] - g.min_extents[Z], + 2 ) * ctx.cone_basesize + else: + cone_scale = ctx.cone_basesize + if ctx.is_lathe: + ctx.mv.rotate(90, 0, 1, 0) + # if Rotation = 180 - back tool + if ctx.stat.rotation_xy == 180: + ctx.mv.rotate(180, 1, 0, 0) + ctx.mv.scale(cone_scale, cone_scale, cone_scale) + ctx.prim.draw_cone(ctx, ctx.colors['cone']) + + def draw_solid(self, ctx: FrameContext, current_tool: Any) -> None: + """Draw a large-diameter tool (diameter > view_tool_min_dia) at the + current model-view stack transform. Replaces the legacy 'tool' display + list: a Lambert-shaded cylinder for mills, the lathe-tool wireframe/ + profile for lathe tools. Mirrors the old cache_tool branching.""" + if ctx.is_lathe and current_tool and current_tool.orientation != 0: + glBlendColor(0, 0, 0, ctx.colors['lathetool_alpha']) + self.draw_lathetool(ctx, current_tool) + elif ctx.is_lathe: + pass # legacy drew nothing for a lathe tool with orientation 0 + else: + glBlendColor(0, 0, 0, ctx.colors['tool_alpha']) + dia = current_tool.diameter + r = ctx.to_internal_linear_unit(dia) / 2. + ctx.prim.draw_cone(ctx, ctx.colors['cone'], + mesh_verts=glcanon_bake.cylinder_mesh(r, 8 * r)) + + @staticmethod + def _fan_to_triangles( + fan: Sequence[tuple[float, float, float]] + ) -> list[tuple[float, float, float]]: + """Expand a GL_TRIANGLE_FAN vertex list into a flat GL_TRIANGLES list.""" + tris: list[tuple[float, float, float]] = [] + for i in range(1, len(fan) - 1): + tris.extend((fan[0], fan[i], fan[i + 1])) + return tris + + @contextmanager + def _lathetool_scope(self, ctx: FrameContext) -> Iterator[None]: + """Depth-always so the tool shows over the geometry; the profile is + double-sided, so culling is disabled around it. Nested under the + caller's GL_ONE/GL_CONSTANT_ALPHA blend (lathetool_alpha), which still + applies. A plain statement pair here would leak depth-ALWAYS onto the + rest of the frame if drawing raised.""" + glDepthFunc(GL_ALWAYS) + glDisable(GL_CULL_FACE) # lathe tool must be visible from both sides + try: + yield + finally: + glEnable(GL_CULL_FACE) + glDepthFunc(GL_LESS) + + def draw_lathetool(self, ctx: FrameContext, + current_tool: Any) -> None: + """Draw the lathe-tool cross-hairs and profile at the current model-view + stack transform, through the line/flat shaders (replaces the immediate- + mode lathetool).""" + with self._lathetool_scope(ctx): + self._draw_lathetool_geometry(ctx, current_tool) + + def _draw_lathetool_geometry(self, ctx: FrameContext, + current_tool: Any) -> None: + diameter, frontangle, backangle, orientation = current_tool[-4:] + w = 3/8. + radius = ctx.to_internal_linear_unit(diameter) / 2. + mvp = ctx.mv.mvp() + color = ctx.colors['lathetool'] + + cross = [(-radius/2.0, 0.0, 0.0), (radius/2.0, 0.0, 0.0), + (0.0, 0.0, -radius/2.0), (0.0, 0.0, radius/2.0)] + ctx.prim.draw_lines(ctx, cross, color, mvp=mvp) + + fan = [] + if orientation == 9: + for i in range(37): + t = i * math.pi / 18 + fan.append((radius * math.cos(t), 0.0, radius * math.sin(t))) + else: + dx, dy = self.LATHE_SHAPES[orientation] + min_angle = min(backangle, frontangle) * math.pi / 180 + max_angle = max(backangle, frontangle) * math.pi / 180 + sinmax = math.sin(max_angle); cosmax = math.cos(max_angle) + sinmin = math.sin(min_angle); cosmin = math.cos(min_angle) + circleminangle = - math.pi/2 + min_angle + circlemaxangle = - 3*math.pi/2 + max_angle + sz = max(w, 3*radius) + fan.append((radius*dx + radius*math.sin(circleminangle) + sz*sinmin, + 0.0, + radius*dy + radius*math.cos(circleminangle) + sz*cosmin)) + for i in range(37): + t = circleminangle + i * (circlemaxangle - circleminangle)/36. + fan.append((radius*dx + radius*math.sin(t), 0.0, + radius*dy + radius*math.cos(t))) + fan.append((radius*dx + radius*math.sin(circlemaxangle) + sz*sinmax, + 0.0, + radius*dy + radius*math.cos(circlemaxangle) + sz*cosmax)) + + if len(fan) >= 3: + ctx.renderer.draw_flat_array( + mvp, + ctx.prim.lines_to_array(self._fan_to_triangles(fan), color), + mode=GL_TRIANGLES) + glUseProgram(0); glBindVertexArray(0) + + +class OverlayPart(Part): + """The DRO overlay: backdrop quad, position text and home/limit icons. + + The only part that draws in screen space rather than world space: it runs + with depth test and depth writes off so nothing behind it can reject a + glyph. + """ + + @contextmanager + def scope(self, ctx: FrameContext) -> Iterator[None]: + """Screen-space: no depth test, no depth writes, so nothing behind the + overlay can reject a glyph. Blend stays at the baseline - the same + source-alpha function the world pass already set.""" + with super().scope(ctx): + glDisable(GL_DEPTH_TEST) + glDepthMask(GL_FALSE) + try: + yield + finally: + glDepthMask(GL_TRUE) + glEnable(GL_DEPTH_TEST) + + def __init__(self) -> None: + #: glyph atlas the icon pass draws into - an + #: ``rs274.glcanon_gl.GlyphAtlas``, handed over through + #: ``ctx.font_info()`` and so not imported here. + self._atlas: Any = None + self._screen: tuple[int, int] = (0, 0) + self._fg: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0) + self._pen_x = 0 # running icon pen, per DRO line + self._pen_y = 0 + #: the backdrop quad trails the text by one frame, as it always has + self.backdrop = False + self._home_shown: list[int] = [] + self._limit_shown: list[int] = [] + + def reset_icons(self) -> None: + """Start a frame: each idx shows its home/limit icon at most once.""" + self._home_shown = [] + self._limit_shown = [] + + def show_icon(self, idx: int, icon: array.array) -> None: + # only show icon once for idx for home,limit icons + # accommodates hal_gremlin override format_dro() + # and prevents display for both Rad and Dia + if icon is homeicon: + if idx in self._home_shown: return + self._home_shown.append(idx) + if icon is limiticon: + if idx in self._limit_shown: return + self._limit_shown.append(idx) + # Textured-quad replacement for the legacy + # glBitmap(13, 16, xorig=0, yorig=3, xmove=17, ...): draw at the running + # pen offset down by the 3px y origin, then advance the pen 17px as + # glBitmap advanced the raster position after each icon. + self._atlas.draw_icon(id(icon), icon, self._pen_x, self._pen_y - 3, + 13, 16, self._fg, self._screen) + self._pen_x += 17 + + def draw(self, ctx: FrameContext) -> None: + limit, homed, posstrs, droposstrs = ctx.posstrs() + + charwidth, linespace, base = ctx.font_info() + + pixel_width = charwidth * max(len(p) for p in posstrs) + + # base is now an opaque glyph-atlas handle (rs274.glcanon_gl). + atlas = base + screen = (ctx.width, ctx.height) + # Top of the overlay in the atlas's screen-pixel space (origin bottom- + # left); was the glOrtho top before the core overlay pass replaced it. + ypos = ctx.height + + # the backdrop latches on the first DRO frame, so it follows the text + # in by one frame - as it always has. + if self.backdrop: + # overlay_alpha as src alpha over a black quad darkens the backdrop + # to (1-overlay_alpha), matching the legacy GL_ONE/GL_CONSTANT_ALPHA. + bg = ctx.colors['overlay_background'] + top = ctx.height + bottom = top - 8 - linespace*len(posstrs) + atlas.draw_quad(0, bottom, pixel_width+42, top, + (bg[0], bg[1], bg[2], ctx.colors['overlay_alpha']), + screen) + + maxlen = 0 + ypos -= linespace+5 + + self.reset_icons() + stringstart_xpos = 15 + #----------------------------------------------------------------------- + if ctx.show_offsets: thestring = droposstrs + else: thestring = posstrs + + self.backdrop = True + fg = tuple(ctx.colors['overlay_foreground']) + (1.0,) + # State the textured-quad icon pass (show_icon) reads. + self._atlas = atlas + self._screen = screen + self._fg = fg + for string in thestring: + maxlen = max(maxlen, len(string)) + atlas.draw_string(string, stringstart_xpos, ypos, fg, screen) + + idx = ctx.icon_index(string) + if (idx == -1): # skip icon display for this line + if (len(string) != 0): ypos -= linespace + continue + + # Reset the icon pen for this line (was glRasterPos2i(0, ypos); + # glBitmap advanced x by 17 per icon). + self._pen_x = 0 + self._pen_y = ypos + if (idx == -2 or idx == -6): # use allhomedicon + ctx.show_icon(idx, allhomedicon) + if (idx == -4 or idx == -6): # use somelimiticon + ctx.show_icon(idx, somelimiticon) + if (idx <= -2): + ypos -= linespace + continue + + if ( ctx.joints_mode + or (ctx.stat.kinematics_type == linuxcnc.KINEMATICS_IDENTITY) + ): + if homed[idx]: + ctx.show_icon(idx, homeicon) + if limit[idx]: + ctx.show_icon(idx, limiticon) + ypos -= linespace + continue + + # extra joint after homing, world mode + if ((ctx.stat.num_extrajoints>0) and (not ctx.joints_mode)): + ctx.show_icon(idx, homeicon) + if limit[idx]: + ctx.show_icon(idx, limiticon) + + ypos -= linespace + + +class PreviewScene(Scene): + """The preview's part order, and the gates deciding what participates. + + Every gate is owned here, by the parent: a part is handed a frame to draw + in, never the question of whether it should be drawn at all. + """ + + def __init__(self) -> None: + # Named as well as ordered: GlCanonDraw's kept-for-compatibility + # drawing methods (draw_grid, show_extents, ...) delegate to these. + self.grid = GridPart() + self.program = ProgramPart() + # Shares the program's geometry, and must sit immediately after it: the + # highlight is a second draw of the same buffers, over the first. + self.highlight = HighlightPart(self.program.resource) + self.extents = ExtentsPart() + self.bounding_box = BoundingBoxPart() + self.user_plot = UserPlotPart() + self.relative_coords = RelativeCoordPart() + self.limits_box = LimitsBoxPart() + self.backplot = BackplotPart() + self.tool = ToolPart() + self.overlay = OverlayPart() + super().__init__([ + (self.grid, lambda ctx: bool(ctx.grid_size)), + (self.program, lambda ctx: ctx.show_program), + (self.highlight, lambda ctx: ctx.show_program + and ctx.highlight_line is not None), + (self.extents, lambda ctx: self.program_outline(ctx)[0]), + (self.bounding_box, lambda ctx: self.program_outline(ctx)[1]), + (self.user_plot, lambda ctx: ctx.user_plot is not None), + (self.relative_coords, lambda ctx: ctx.show_live_plot + or ctx.show_program), + (self.limits_box, lambda ctx: ctx.show_limits), + (self.backplot, lambda ctx: ctx.show_live_plot), + (self.tool, lambda ctx: ctx.show_tool), + (self.overlay, lambda ctx: ctx.enable_dro), + ]) + + @staticmethod + def program_outline(ctx: FrameContext) -> tuple[Any, bool]: + """How the loaded program's bounds are outlined, as + ``(dimension_lines, plain_box)``. + + The two are one rule, not two: with the program drawn, the dimension + lines follow ``show_extents`` and the plain box would be redundant; + with the program hidden, both stand in for it regardless of the flag. + Split across two parts this had to be kept mutually consistent with + nowhere to read the rule it jointly encoded. + """ + if ctx.canon is None: + return False, False + if ctx.show_program: + return ctx.show_extents, False + return True, True diff --git a/src/emc/usr_intf/axis/extensions/emcmodule.cc b/src/emc/usr_intf/axis/extensions/emcmodule.cc index 00ef2e7c6b9..406018f3f83 100644 --- a/src/emc/usr_intf/axis/extensions/emcmodule.cc +++ b/src/emc/usr_intf/axis/extensions/emcmodule.cc @@ -2657,12 +2657,16 @@ static void vertex9(const double pt[9], double p[3], const char *geometry) { } } +// Deprecated: uses immediate-mode OpenGL (glVertex3dv), which is removed in +// the 3.3 core profile now used for preview rendering. static void glvertex9(const double pt[9], const char *geometry) { double p[3]; vertex9(pt, p, geometry); glVertex3dv(p); } +// Deprecated: emits immediate-mode OpenGL vertices via glvertex9(), invalid +// in the 3.3 core profile now used for preview rendering. static void line9(const double p1[9], const double p2[9], const char *geometry) { if(p1[3] != p2[3] || p1[4] != p2[4] || p1[5] != p2[5]) { double dc = std::max({ @@ -2684,6 +2688,8 @@ static void line9(const double p1[9], const double p2[9], const char *geometry) } } +// Deprecated: emits immediate-mode OpenGL vertices via glvertex9(), invalid +// in the 3.3 core profile now used for preview rendering. static void line9b(const double p1[9], const double p2[9], const char *geometry) { glvertex9(p1, geometry); if(p1[3] != p2[3] || p1[4] != p2[4] || p1[5] != p2[5]) { @@ -2766,6 +2772,8 @@ static PyObject *pygui_rot_offsets(PyObject * /*s*/, PyObject *o) { return Py_None; } +// Deprecated: draws with immediate-mode OpenGL (glBegin/glVertex*/glEnd), +// invalid in the 3.3 core profile now used for preview rendering. static PyObject *pydraw_lines(PyObject * /*s*/, PyObject *o) { PyListObject *li; int for_selection = 0; @@ -2818,6 +2826,8 @@ static PyObject *pydraw_lines(PyObject * /*s*/, PyObject *o) { return Py_None; } +// Deprecated: draws with immediate-mode OpenGL (glBegin/glVertex*/glEnd), +// invalid in the 3.3 core profile now used for preview rendering. static PyObject *pydraw_dwells(PyObject * /*s*/, PyObject *o) { PyListObject *li; int for_selection = 0, is_lathe = 0, i, n; @@ -3214,6 +3224,31 @@ static PyObject *Logger_last(pyPositionLogger *s, PyObject *o) { return result; } +// Additive accessor for the OpenGL 3.3 core renderer: hand Python a private +// copy of the logged-point ring buffer so it can upload changed ranges to a +// VBO, instead of the deprecated immediate-mode Logger_call. The copy is taken +// under the same lock that guards realloc/memmove of s->p in the sampler +// thread. Returns (bytes, npts, is_xyuv); each point is a `struct logger_point` +// (see the matching numpy dtype in rs274.glcanon_bake.LOGGER_DTYPE). +// +// This is the core renderer's draw-time handoff of the plotted points, so it +// advances lpts to npts exactly as Logger_call did. Logger_last(flag=1) reads +// lpts to report the last *drawn* point (used to position the tool marker so it +// stays in sync with the plotted line); without this, lpts stays 0 and the tool +// marker snaps to the origin whenever the live plot is shown. +static PyObject *Logger_get_points(pyPositionLogger *s, PyObject * /*o*/) { + LOCK(); + int npts = s->npts; + if(npts < 0) npts = 0; + Py_ssize_t nbytes = (Py_ssize_t)npts * (Py_ssize_t)sizeof(struct logger_point); + PyObject *buf = PyBytes_FromStringAndSize((const char*)s->p, nbytes); + int is_xyuv = s->is_xyuv; + s->lpts = s->npts; + UNLOCK(); + if(!buf) return NULL; + return Py_BuildValue("Nii", buf, npts, is_xyuv); +} + static PyMemberDef Logger_members[] = { {(char*)"npts", T_INT, offsetof(pyPositionLogger, npts), READONLY, NULL}, {}, @@ -3228,6 +3263,9 @@ static PyMethodDef Logger_methods[] = { "Stop the position logger"}, {"call", (PyCFunction)Logger_call, METH_NOARGS, "Plot the backplot now"}, + {"points", (PyCFunction)Logger_get_points, METH_NOARGS, + "Return (bytes, npts, is_xyuv): a copy of the logged point buffer for " + "VBO upload by the core renderer"}, {"set_depth", (PyCFunction)Logger_set_depth, METH_VARARGS, "set the Z and W depths for foam cutter"}, {"set_colors", (PyCFunction)Logger_set_colors, METH_VARARGS, diff --git a/src/emc/usr_intf/axis/extensions/togl.c b/src/emc/usr_intf/axis/extensions/togl.c index 58b251f3bf3..23e54b61bef 100644 --- a/src/emc/usr_intf/axis/extensions/togl.c +++ b/src/emc/usr_intf/axis/extensions/togl.c @@ -228,6 +228,7 @@ struct Togl int StereoFlag; int AuxNumber; int Indirect; + int CoreProfileFlag; /* request an OpenGL 3.3 core-profile context */ char *ShareList; /* name (ident) of Togl to share dlists with */ char *ShareContext; /* name (ident) to share OpenGL context with */ @@ -357,6 +358,9 @@ static Tk_ConfigSpec configSpecs[] = { {TK_CONFIG_BOOLEAN, "-stereo", "stereo", "Stereo", "false", Tk_Offset(struct Togl, StereoFlag), 0, NULL}, + {TK_CONFIG_BOOLEAN, "-coreprofile", "coreprofile", "CoreProfile", + "false", Tk_Offset(struct Togl, CoreProfileFlag), 0, NULL}, + #ifndef NO_TK_CURSOR { TK_CONFIG_ACTIVE_CURSOR, "-cursor", "cursor", "Cursor", "", Tk_Offset(struct Togl, Cursor), TK_CONFIG_NULL_OK, NULL }, @@ -1609,6 +1613,21 @@ static LRESULT CALLBACK Win32WinProc( HWND hwnd, UINT message, #endif /* WIN32 */ +/* GLX_ARB_create_context tokens (from GL/glxext.h), defined here so the core- + * profile path builds even against an older glx.h. */ +#ifndef GLX_CONTEXT_MAJOR_VERSION_ARB +#define GLX_CONTEXT_MAJOR_VERSION_ARB 0x2091 +#endif +#ifndef GLX_CONTEXT_MINOR_VERSION_ARB +#define GLX_CONTEXT_MINOR_VERSION_ARB 0x2092 +#endif +#ifndef GLX_CONTEXT_PROFILE_MASK_ARB +#define GLX_CONTEXT_PROFILE_MASK_ARB 0x9126 +#endif +#ifndef GLX_CONTEXT_CORE_PROFILE_BIT_ARB +#define GLX_CONTEXT_CORE_PROFILE_BIT_ARB 0x00000001 +#endif + /* * Togl_CreateWindow @@ -1672,6 +1691,70 @@ static Window Togl_CreateWindow(Tk_Window tkwin, togl->GlCtx = shareWith->GlCtx; printf("SHARE CTX\n"); } + else if (togl->CoreProfileFlag) { + /* OpenGL 3.3 core-profile context via an FBConfig and + * glXCreateContextAttribsARB. Used by AXIS's modern preview renderer; + * the default (below) is left untouched so vismach and other Togl users + * keep their legacy contexts. */ + int fb_attribs[] = { + GLX_X_RENDERABLE, True, + GLX_DRAWABLE_TYPE, GLX_WINDOW_BIT, + GLX_RENDER_TYPE, GLX_RGBA_BIT, + GLX_RED_SIZE, togl->RgbaRed, + GLX_GREEN_SIZE, togl->RgbaGreen, + GLX_BLUE_SIZE, togl->RgbaBlue, + GLX_DEPTH_SIZE, (togl->DepthFlag ? togl->DepthSize : 24), + GLX_DOUBLEBUFFER, (togl->DoubleFlag ? True : False), + None + }; + int nconfigs = 0; + GLXFBConfig *fbconfigs; + GLXFBConfig fbconfig; + GLXContext (*createContextAttribs)(Display*, GLXFBConfig, GLXContext, + Bool, const int*); + int ctx_attribs[] = { + GLX_CONTEXT_MAJOR_VERSION_ARB, 3, + GLX_CONTEXT_MINOR_VERSION_ARB, 3, + GLX_CONTEXT_PROFILE_MASK_ARB, GLX_CONTEXT_CORE_PROFILE_BIT_ARB, + None + }; + + fbconfigs = glXChooseFBConfig(dpy, Tk_ScreenNumber(togl->TkWin), + fb_attribs, &nconfigs); + if (!fbconfigs || nconfigs < 1) { + Tcl_SetResult(togl->Interp, "Togl: no framebuffer config for OpenGL " + "3.3 core; requires Mesa (try LIBGL_ALWAYS_SOFTWARE=1)", TCL_STATIC); + return DUMMY_WINDOW; + } + fbconfig = fbconfigs[0]; + visinfo = glXGetVisualFromFBConfig(dpy, fbconfig); + XFree(fbconfigs); + if (!visinfo) { + Tcl_SetResult(togl->Interp, + "Togl: glXGetVisualFromFBConfig failed for OpenGL 3.3 core", + TCL_STATIC); + return DUMMY_WINDOW; + } + + createContextAttribs = (GLXContext (*)(Display*, GLXFBConfig, GLXContext, + Bool, const int*)) + glXGetProcAddressARB((const GLubyte*)"glXCreateContextAttribsARB"); + if (!createContextAttribs) { + Tcl_SetResult(togl->Interp, "Togl: glXCreateContextAttribsARB " + "unavailable; OpenGL 3.3 core required (try LIBGL_ALWAYS_SOFTWARE=1)", + TCL_STATIC); + return DUMMY_WINDOW; + } + + if (togl->Indirect) directCtx = GL_FALSE; + togl->GlCtx = createContextAttribs(dpy, fbconfig, NULL, directCtx, + ctx_attribs); + if (togl->GlCtx == NULL) { + Tcl_SetResult(togl->Interp, "Togl: could not create an OpenGL 3.3 " + "core context (try LIBGL_ALWAYS_SOFTWARE=1)", TCL_STATIC); + return DUMMY_WINDOW; + } + } else { int attempt; /* It may take a few tries to get a visual */ diff --git a/src/emc/usr_intf/axis/scripts/axis.py b/src/emc/usr_intf/axis/scripts/axis.py index ddc9baa4335..75604eb8986 100755 --- a/src/emc/usr_intf/axis/scripts/axis.py +++ b/src/emc/usr_intf/axis/scripts/axis.py @@ -664,12 +664,11 @@ def redraw_dro(self): self.last_font = new_font def init(): - glDrawBuffer(GL_BACK) - glDisable(GL_CULL_FACE) - glLineStipple(2, 0x5555) - glDisable(GL_LIGHTING) - glClearColor(0,0,0,0) - glPixelStorei(GL_UNPACK_ALIGNMENT, 1) + # The OpenGL 3.3 core preview renderer sets all needed GL state per frame + # (GlCanonDraw.realize and redraw), so the legacy fixed-function init here + # (glLineStipple/glDisable(GL_LIGHTING)/... - removed from core profiles) is + # gone. + pass def toggle_perspective(e): o.perspective = not o.perspective @@ -1841,11 +1840,6 @@ def prompt_touchoff(title, text, default, tool_only, system=None): ('b', _("B bounds:")),('c', _("C bounds:")) ] -def dist(xxx_todo_changeme, xxx_todo_changeme1): - (x,y,z) = xxx_todo_changeme - (p,q,r) = xxx_todo_changeme1 - return ((x-p)**2 + (y-q)**2 + (z-r)**2) ** .5 - # returns units/sec def get_jog_speed(a): if vars.teleop_mode.get(): @@ -2087,16 +2081,10 @@ def gcode_properties(event=None): fmt = "%.4f" mf = vars.max_speed.get() - #print o.canon.traverse[0] - - g0 = sum(dist(l[1][:3], l[2][:3]) for l in o.canon.traverse) - g1 = (sum(dist(l[1][:3], l[2][:3]) for l in o.canon.feed) + - sum(dist(l[1][:3], l[2][:3]) for l in o.canon.arcfeed)) - gt = (sum(dist(l[1][:3], l[2][:3])/min(mf, l[3]) for l in o.canon.feed) + - sum(dist(l[1][:3], l[2][:3])/min(mf, l[3]) for l in o.canon.arcfeed) + - sum(dist(l[1][:3], l[2][:3])/mf for l in o.canon.traverse) + - o.canon.dwell_time - ) + + g0 = o.canon.g0_length + g1 = o.canon.g1_length + gt = o.canon.run_time(mf) props['g0'] = "%f %s".replace("%f", fmt) % (from_internal_linear_unit(g0, conv), units) props['g1'] = "%f %s".replace("%f", fmt) % (from_internal_linear_unit(g1, conv), units) @@ -3853,7 +3841,10 @@ def unique_axes(axes, order): c.set_optional_stop(vars.optional_stop.get()) c.wait_complete() -o = MyOpengl(widgets.preview_frame, width=400, height=300, double=1, depth=1) +# coreprofile=1 requests an OpenGL 3.3 core context from the vendored Togl for +# the modern preview renderer (see togl.c -coreprofile). AXIS always enables it. +o = MyOpengl(widgets.preview_frame, width=400, height=300, double=1, depth=1, + coreprofile=1) o.last_line = 1 o.pack(fill="both", expand=1) diff --git a/src/emc/usr_intf/gremlin/gremlin.py b/src/emc/usr_intf/gremlin/gremlin.py index fbc3073fbfd..09d321bac6e 100755 --- a/src/emc/usr_intf/gremlin/gremlin.py +++ b/src/emc/usr_intf/gremlin/gremlin.py @@ -51,6 +51,41 @@ from OpenGL import GL from ctypes import * +# The 3.3-core GLX context is created and bound through libGL directly with +# ctypes: PyOpenGL cannot resolve the glXCreateContextAttribsARB extension entry +# point (it comes back as a null function), so we go to the driver ourselves and +# keep make-current/swap on the same handle to avoid mixing context pointers. +_glx = CDLL("libGL.so.1") +_glx.glXChooseFBConfig.restype = POINTER(c_void_p) +_glx.glXChooseFBConfig.argtypes = [c_void_p, c_int, POINTER(c_int), POINTER(c_int)] +_glx.glXGetProcAddress.restype = c_void_p +_glx.glXGetProcAddress.argtypes = [c_char_p] +_glx.glXMakeCurrent.restype = c_int +_glx.glXMakeCurrent.argtypes = [c_void_p, c_ulong, c_void_p] +_glx.glXSwapBuffers.restype = None +_glx.glXSwapBuffers.argtypes = [c_void_p, c_ulong] +_ccaa_proc = _glx.glXGetProcAddress(b"glXCreateContextAttribsARB") +_glXCreateContextAttribsARB = CFUNCTYPE( + c_void_p, c_void_p, c_void_p, c_void_p, c_int, POINTER(c_int))( + _ccaa_proc) if _ccaa_proc else None + +# FBConfig / GLX_ARB_create_context(_profile) attribute tokens (stable GLX ints). +GLX_X_RENDERABLE = 0x8012 +GLX_DRAWABLE_TYPE = 0x8010 +GLX_WINDOW_BIT = 0x00000001 +GLX_RENDER_TYPE = 0x8011 +GLX_RGBA_BIT = 0x00000001 +GLX_RED_SIZE = 8 +GLX_GREEN_SIZE = 9 +GLX_BLUE_SIZE = 10 +GLX_ALPHA_SIZE = 11 +GLX_DEPTH_SIZE = 12 +GLX_DOUBLEBUFFER = 5 +GLX_CONTEXT_MAJOR_VERSION_ARB = 0x2091 +GLX_CONTEXT_MINOR_VERSION_ARB = 0x2092 +GLX_CONTEXT_PROFILE_MASK_ARB = 0x9126 +GLX_CONTEXT_CORE_PROFILE_BIT_ARB = 0x00000001 + try: import Xlib from Xlib.display import Display @@ -116,18 +151,10 @@ def get_attributes(self): return (c_int * len(attrs))(*attrs) def __init__(self, inifile): - - self.xwindow_id = None - self.add_attribute(GLX.GLX_RGBA, True) - self.add_attribute(GLX.GLX_RED_SIZE, 1) - self.add_attribute(GLX.GLX_GREEN_SIZE, 1) - self.add_attribute(GLX.GLX_BLUE_SIZE, 1) - self.add_attribute(GLX.GLX_DOUBLEBUFFER, 1) + self.xwindow_id = None - xvinfo = GLX.glXChooseVisual(self.xdisplay, self.display.get_default_screen(), self.get_attributes()) - configs = GLX.glXChooseFBConfig(self.xdisplay, 0, None, byref(c_int())) - self.context = GLX.glXCreateContext(self.xdisplay, xvinfo, None, True) + self._create_core_context() Gtk.DrawingArea.__init__(self) glnav.GlNavBase.__init__(self) @@ -210,23 +237,75 @@ def C(s): if self.stat.axis_mask & (1< + gradient_color2 top) through the flat shader, replacing the legacy + immediate-mode GL_QUADS.""" + GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT) + c1 = self.gradient_color1 + c2 = self.gradient_color2 + + def v(x, y, c): + return (x, y, 0.0, c[0], c[1], c[2], 1.0, 0.0, 0.0) + verts = np.array([v(-1, -1, c1), v(1, -1, c1), v(1, 1, c2), + v(-1, -1, c1), v(1, 1, c2), v(-1, 1, c2)], + dtype=np.float32) + GL.glDisable(GL.GL_DEPTH_TEST) + self._ensure_renderer().draw_flat_array( + glnav.identity_matrix(), verts, mode=GL.GL_TRIANGLES) + GL.glUseProgram(0) + GL.glBindVertexArray(0) + GL.glEnable(GL.GL_DEPTH_TEST) + + def redraw_perspective(self): + w = self.winfo_width() + h = self.winfo_height() + GL.glViewport(0, 0, w, h) + self._clear_background() + self._projection = self.get_projection_matrix(w, h) + self._mv_reset(self.get_modelview_matrix()) try: self.redraw() finally: - GL.glFlush() # Tidy up - GL.glPopMatrix() # Restore the matrix + GL.glFlush() - # override glcanon function def redraw_ortho(self): if not self.initialised: return w = self.winfo_width() h = self.winfo_height() GL.glViewport(0, 0, w, h) - if self.use_gradient_background: - GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT) - GL.glMatrixMode(GL.GL_PROJECTION) - GL.glLoadIdentity() - - GL.glMatrixMode(GL.GL_MODELVIEW) - GL.glPushMatrix() - GL.glPushMatrix() - GL.glLoadIdentity() - - GL.glDisable(GL.GL_DEPTH_TEST) - GL.glBegin(GL.GL_QUADS) - #//bottom color - color = self.gradient_color1 - GL.glColor3f(color[0],color[1],color[2]) - GL.glVertex2f(-1.0, -1.0) - GL.glVertex2f(1.0, -1.0) - #//top color - color = self.gradient_color2 - GL.glColor3f(color[0],color[1],color[2]) - GL.glVertex2f(1.0, 1.0) - GL.glVertex2f(-1.0, 1.0) - GL.glEnd() - GL.glEnable(GL.GL_DEPTH_TEST) - - GL.glPopMatrix() - GL.glPopMatrix() - - else: - # Clear the background and depth buffer. - GL.glClearColor(*(self.colors['back'] + (0,))) - GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT) - - GL.glMatrixMode(GL.GL_PROJECTION) - GL.glLoadIdentity() - ztran = self.distance - k = (abs(ztran or 1)) ** .55555 - l = k * h / w - GL.glOrtho(-k, k, -l, l, -1000, 1000.) - GLU.gluLookAt(0, 0, 1, - 0, 0, 0, - 0., 1., 0.) - GL.glMatrixMode(GL.GL_MODELVIEW) - GL.glPushMatrix() + self._clear_background() + self._projection = self.get_projection_matrix(w, h) + self._mv_reset(self.get_modelview_matrix()) try: self.redraw() finally: - GL.glFlush() # Tidy up - GL.glPopMatrix() # Restore the matrix - - # override glcanon function - def basic_lighting(self): - GL.glLightfv(GL.GL_LIGHT0, GL.GL_POSITION, (1, -1, 1, 0)) - GL.glLightfv(GL.GL_LIGHT0, GL.GL_AMBIENT, self.colors['tool_ambient'] + (0,)) - GL.glLightfv(GL.GL_LIGHT0, GL.GL_DIFFUSE, self.colors['tool_diffuse'] + (0,)) - GL.glMaterialfv(GL.GL_FRONT_AND_BACK, GL.GL_AMBIENT, (.6,.6,.6,0)) - GL.glMaterialfv(GL.GL_FRONT_AND_BACK, GL.GL_DIFFUSE, (1,1,1,0)) - GL.glEnable(GL.GL_LIGHTING) - GL.glEnable(GL.GL_LIGHT0) - GL.glDepthFunc(GL.GL_LESS) - GL.glEnable(GL.GL_DEPTH_TEST) - GL.glMatrixMode(GL.GL_MODELVIEW) - GL.glLoadIdentity() + GL.glFlush() # resizes the view to fit the window def resizeGL(self, width, height): - side = min(width, height) - if side < 0: - return - GL.glViewport((width - side) // 2, (height - side) // 2, side, side) - GL.glMatrixMode(GL.GL_PROJECTION) # To operate on projection-view matrix - GL.glLoadIdentity() # reset the model-view matrix - GL.glOrtho(-0.5, +0.5, +0.5, -0.5, 4.0, 15.0) - GL.glMatrixMode(GL.GL_MODELVIEW) # To operate on model-view matrix + # redraw sets the viewport and projection from the camera each frame; + # nothing to configure on the (now core) GL matrix stack here. + GL.glViewport(0, 0, width, height) #################################### # Property setting functions @@ -1181,103 +1094,6 @@ def setView(self,v,z,x,y,lat=None,lon=None): self.panView(x, y) self.set_viewangle(lat, lon) - ############################################################ - # display for when linuxcnc isn't runnimg - forQTDesigner - ############################################################ - def makeObject(self): - genList = GL.glGenLists(1) - GL.glNewList(genList, GL.GL_COMPILE) - - GL.glBegin(GL.GL_QUADS) - factor = 4 - # Make a tee section - x1 = +0.06 * factor - y1 = -0.14 * factor - x2 = +0.14 * factor - y2 = -0.06 * factor - x3 = +0.08 * factor - y3 = +0.00 * factor - x4 = +0.30 * factor - y4 = +0.22 * factor - - # cross - self.quad(x1, y1, x2, y2, y2, x2, y1, x1, z= .05, color = self.Green) - # vertical line - self.quad(x3, y3, x4, y4, y4, x4, y3, x3, z= .05, color = self.Green) - - # cross depth - self.extrude(x1, y1, x2, y2, z= .05, color = self.Green) - self.extrude(x2, y2, y2, x2, z= .05, color = self.Green) - self.extrude(y2, x2, y1, x1, z= .05, color = self.Green) - self.extrude(y1, x1, x1, y1, z= .05, color = self.Green) - - # vertical depth - self.extrude(x3, y3, x4, y4, z= .05, color = self.Green) - self.extrude(x4, y4, y4, x4, z= .05, color = self.Green) - self.extrude(y4, x4, y3, x3, z= .05, color = self.Green) - self.extrude(y3, x3, x3, y3, z= .05, color = self.Green) - - NumSectors = 200 - - # Make a circle - for i in range(NumSectors): - angle1 = (i * 2 * math.pi) / NumSectors - x5 = 0.30 * math.sin(angle1) * factor - y5 = 0.30 * math.cos(angle1) * factor - x6 = 0.20 * math.sin(angle1) * factor - y6 = 0.20 * math.cos(angle1) * factor - - angle2 = ((i + 1) * 2 * math.pi) / NumSectors - x7 = 0.20 * math.sin(angle2) * factor - y7 = 0.20 * math.cos(angle2) * factor - x8 = 0.30 * math.sin(angle2) * factor - y8 = 0.30 * math.cos(angle2) * factor - - self.quad(x5, y5, x6, y6, x7, y7, x8, y8, z= .05, color = self.Green) - - self.extrude(x6, y6, x7, y7, z= .05, color = self.Green) - self.extrude(x8, y8, x5, y5, z= .05, color = self.Green) - - GL.glEnd() - GL.glEndList() - - return genList - - def quad(self, x1, y1, x2, y2, x3, y3, x4, y4, z, color): - self.qglColor(color) - - GL.glVertex3d(x1, y1, -z) - GL.glVertex3d(x2, y2, -z) - GL.glVertex3d(x3, y3, -z) - GL.glVertex3d(x4, y4, -z) - - GL.glVertex3d(x4, y4, +z) - GL.glVertex3d(x3, y3, +z) - GL.glVertex3d(x2, y2, +z) - GL.glVertex3d(x1, y1, +z) - - def lathe_quad(self, x1, x2, x3, x4, z1, z2, z3, z4, color): - self.qglColor(color) - - GL.glVertex3d(x1, 0, z1) - GL.glVertex3d(x2, 0, z2) - GL.glVertex3d(x3, 0, z3) - GL.glVertex3d(x4, 0, z4) - - # defeat back face cull - GL.glVertex3d(x4, 0, z4) - GL.glVertex3d(x3, 0, z3) - GL.glVertex3d(x2, 0, z2) - GL.glVertex3d(x1, 0, z1) - - def extrude(self, x1, y1, x2, y2, z, color): - self.qglColor(color) - - GL.glVertex3d(x1, y1, +z) - GL.glVertex3d(x2, y2, +z) - GL.glVertex3d(x2, y2, -z) - GL.glVertex3d(x1, y1, -z) - ############# # QProperties ############# From 38a22a61ced1c8e25fb2ab39e9baf9b76f6fd321 Mon Sep 17 00:00:00 2001 From: Alexey Presnyakov <309782758+alex-pres@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:17:28 +0400 Subject: [PATCH 2/8] gcode: add an opt-in move-batch protocol to the preview parser Per-move C->Python callbacks are about two thirds of a 1M-move parse. A canon may now set use_move_batches and supply move_batch, receiving moves and numeric non-move events as fixed-width float64 rows in bulk through a read-only memoryview that reproduces the legacy values bit for bit. Canons that do not opt in keep the call-per-move protocol unchanged. --- lib/python/rs274/glcanon_batch.py | 313 +++++++++++++++++++++++++ src/emc/rs274ngc/gcodemodule.cc | 364 +++++++++++++++++++++++++++++- 2 files changed, 669 insertions(+), 8 deletions(-) create mode 100644 lib/python/rs274/glcanon_batch.py diff --git a/lib/python/rs274/glcanon_batch.py b/lib/python/rs274/glcanon_batch.py new file mode 100644 index 00000000000..3f846de5c64 --- /dev/null +++ b/lib/python/rs274/glcanon_batch.py @@ -0,0 +1,313 @@ +# This is a component of AXIS, a front-end for emc +# +# 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. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""The reference consumer of ``gcode.parse``'s move-batch protocol. + +The protocol is opt-in and lives in ``src/emc/rs274ngc/gcodemodule.cc`` (class +``MoveBatch``): a canon that sets ``use_move_batches`` and provides +``move_batch`` stops receiving ``straight_feed``/``straight_traverse``/ +``straight_probe``/``rigid_tap``/``dwell``/``user_defined_function``/ +``change_tool``/``tool_offset`` as one Python call per event, and receives them +instead as blocks of fixed-width float64 rows. On a million-move file that is +the difference between a million Python frames and about sixteen. + +Each row is 13 float64s:: + + [kind, line_number, x, y, z, a, b, c, u, v, w, feedrate, spindle_speed] + +``kind`` is one of the ``KIND_*`` constants below. Rows of kinds 0-3 are moves +and carry exactly the axis values the legacy per-event call would have passed - +after the interpreter's position update and after the metric conversion - which +is what lets this module reproduce ``GLCanon``'s geometry bit for bit rather +than merely closely. Rows of kinds 4-7 are the numeric non-move events, which +are batched rather than forwarded so that a canned drilling cycle's one dwell +per hole does not shred the moves around it into four-row batches; they carry +their payload in the axis slots (dwell seconds in x; M1xx index/P/Q in x/y/z; +tool number in x; the nine tool offsets in x..w) and zeros elsewhere. + +``feedrate`` is the last ``SET_FEED_RATE`` value, so no correlation with a +separate rate callback is needed. ``spindle_speed`` is the last S word for +spindle 0 - data the legacy protocol never delivered at all, since that canon +call is an empty stub. This consumer ignores it; it is there for the renderer +rewrite to read without another C change. + +**The view is on loan.** ``move_batch`` is handed a read-only memoryview over a +buffer the C module reuses. Copy or fully consume it before returning; a +retained view sees whatever the next batch overwrote it with. + +Ordering is guaranteed by the C side: rows are delivered before any callback +that is still forwarded (comments, offsets, rotation, plane, messages, arcs, +``next_line``), when the buffer fills, once a second, and at end of parse. So +within one batch the suppression state, the g92/g5x offsets, the XY rotation and +the plane are all constant - which is what lets a whole run of moves be +transformed in one vectorized pass. The feed rate is the deliberate exception: +``set_feed_rate`` does *not* flush, because the rate is in the row, so +``self.feedrate`` may already have moved on by the time a batch holding rows +made at the old rate arrives. **Read the column, never the attribute.** It is +also forwarded only when the rate actually changes, since the interpreter +reports an ``F`` word whether or not it changed anything; a consumer that +counted ``set_feed_rate`` calls would see fewer of them than in legacy mode. + +Rows are staged into the canon's own staging chunk rather than filled from here. +That is not about splitting a batch up - a whole batch is filled in one call, +and ``ProgramGeometry.add_moves_raw`` costs the same per row anywhere above +about a thousand of them. It is about putting a *floor* under the piece size: +filled straight from here, a fill would be exactly as big as whatever the C +side happened to deliver, and a batch can be a handful of rows. At 8 rows a +fill costs about 9x per row what it costs at 4096. Staging accumulates until +``FILL_BATCH`` instead, and as a bonus arc segments staged between batches keep +their place without a flush. + +No GUI opts in yet; doing that is a separate change. +""" + +import numpy as np + +import rs274.glcanon +from rs274.glcanon import GLCanon +from rs274 import glcanon_bake +from rs274.glcanon_bake import CAT_TRAVERSE, CAT_FEED + +#: Row width and columns, matching ``MoveBatch::ROW`` and the layout above. +ROW_WIDTH = 13 +COL_KIND = 0 +COL_LINE = 1 +COL_AXES = slice(2, 11) +COL_FEEDRATE = 11 +COL_SPINDLE = 12 + +# Column 0. Must match ``MoveBatch::Kind`` in gcodemodule.cc. +KIND_TRAVERSE = 0 +KIND_FEED = 1 +KIND_PROBE = 2 +KIND_RIGID_TAP = 3 +KIND_DWELL = 4 +KIND_M1XX = 5 +KIND_CHANGE_TOOL = 6 +KIND_TOOL_OFFSET = 7 +#: Kinds at or above this are events, not moves: they end a vectorized run. +FIRST_EVENT_KIND = KIND_DWELL + +#: The nine axis letters, in row order. +_AXES = "xyzabcuvw" + +#: ``CANON_PLANE`` (canon.hh) to the 0/1/2 index ``GLCanon._record_dwell`` +#: derives from ``self.state.plane``: XY/UV -> 0, XZ/UW -> 1, YZ/VW -> 2. +_PLANE_INDEX = {1: 0, 2: 2, 3: 1, 4: 0, 5: 2, 6: 1} + + +class MoveBatchMixin: + """Turns delivered batches into exactly what per-event ``GLCanon`` builds. + + Mix in *before* ``GLCanon`` so the ``_record_dwell`` override below wins. + """ + + #: What the C side reads to choose the protocol, once, at parse start. + use_move_batches = True + + def batch_progress(self, lineno): + """Called once per non-empty batch with the last line number in it. + + The hook a GUI overrides to drive a progress bar. The C side flushes + before its once-a-second abort check, so this fires at least about that + often on a long parse - which is what replaces the per-line ``next_line`` + a GUI used to count. + """ + + # -- the protocol ------------------------------------------------------ + + def move_batch(self, view): + rows = np.frombuffer(view, dtype=np.float64).reshape(-1, ROW_WIDTH) + if len(rows) == 0: + return + # No flush here. Whatever the per-call path staged since the last batch + # - arc segments, which are deliberately not batched - is sitting in the + # canon's staging chunk, and _batch_run appends to that same chunk, so + # emission order is kept by construction. Flushing first would be worse + # than redundant: it would fill once per batch, and a file with an F + # word every few moves delivers batches of a few rows. + kinds = rows[:, COL_KIND].astype(np.int64) + events = np.nonzero(kinds >= FIRST_EVENT_KIND)[0] + start = 0 + for at in events: + if at > start: + self._batch_run(rows[start:at], kinds[start:at]) + self._batch_event(rows[at], int(kinds[at])) + start = int(at) + 1 + if start < len(rows): + self._batch_run(rows[start:], kinds[start:]) + self.batch_progress(int(rows[-1, COL_LINE])) + + # -- a run of move rows ------------------------------------------------ + + def _batch_run(self, rows, kinds): + """Fill one uninterrupted run of move rows, vectorized. + + Everything a move depends on - suppression, the offsets, the rotation, + the tool-offset triple - is constant across a run, because every event + that could change one either flushes the batch (comments, offsets, + rotation) or ends the run (the tool rows). That is the whole reason the + run is the unit of work. + """ + if self.suppress > 0: + # Legacy straight_traverse/feed/probe/rigid_tap all return before + # touching any state while hidden, so a suppressed run leaves even + # `lo` and `first_move` alone. + return + + n = len(rows) + # rotate_and_translate (rs274.interpret.Translated), op for op, on the + # whole run at once. The order matters and is not incidental: the same + # sequence of IEEE adds in the same order is what makes these results + # bit-identical to the per-move canon's, so an "equivalent" rearrangement + # here would turn an exact-equality test into an approximate one. + pts = rows[:, COL_AXES].copy() + pts += self._offsets("g92_offset_") + if self.rotation_xy: + rotx = pts[:, 0] * self.rotation_cos - pts[:, 1] * self.rotation_sin + pts[:, 1] = pts[:, 0] * self.rotation_sin + pts[:, 1] * self.rotation_cos + pts[:, 0] = rotx + pts += self._offsets("g5x_offset_") + + # Where each move starts. Rigid taps do not advance the chain (the + # legacy rigid_tap never assigns self.lo), so consecutive taps all hang + # off the same point. + advances = kinds != KIND_RIGID_TAP + before = np.cumsum(advances) - advances # advancing rows before i + chain = np.empty((n, 9), dtype=np.float64) + chained = before > 0 + chain[~chained] = self.lo + if chained.any(): + source = np.nonzero(advances)[0] + chain[chained] = pts[source[before[chained] - 1]] + + # A tap's endpoint is its transformed x,y,z joined to the chain point's + # a..w - the legacy rigid_tap transforms x,y,z with zeros and then + # splices self.lo's rotary and UVW components back in. + ends = pts.copy() + taps = ~advances + if taps.any(): + ends[taps, 3:] = chain[taps, 3:] + + # Leading traverses while first_move is set move the tool without + # drawing; the first move that is not a traverse clears the flag. + keep_from = 0 + if self.first_move: + drawn = np.nonzero(kinds != KIND_TRAVERSE)[0] + if len(drawn) == 0: + keep_from = n + else: + keep_from = int(drawn[0]) + self.first_move = False + + # Before the early return: a dropped leading traverse still moves the + # tool, and the move after it starts where it left off. + if advances.any(): + self.lo = tuple(pts[np.nonzero(advances)[0][-1]].tolist()) + if keep_from >= n: + return + + # Each surviving row becomes one staging row, except a tap, which + # becomes two - down, and back up the way it came. + index = np.arange(keep_from, n) + repeats = np.where(kinds[keep_from:] == KIND_RIGID_TAP, 2, 1) + index = np.repeat(index, repeats) + total = len(index) + offset_in_move = (np.arange(total) + - np.repeat(np.cumsum(repeats) - repeats, repeats)) + returning = (offset_in_move == 1)[:, np.newaxis] + + # Written straight into the canon's own staging chunk, in bulk, rather + # than filled from here. Not to break the run up - a run that is already + # big is filled in one call, and add_moves_raw costs the same per row + # anywhere above ~1024 - but to stop a *small* one reaching the fill on + # its own. Filled from here, the piece size would be the batch size, and + # a file with an F word every few moves arrives a few rows at a time: + # measured, a fill of 8 rows costs ~9x per row what a fill of 4096 does, + # which is enough to make batch mode slower than the per-move canon it + # replaces. Staging leaves the flush policy exactly where GLCanon has + # it, and reuses one buffer instead of allocating a chunk per run. + traversing = kinds[index] == KIND_TRAVERSE + self._reserve_staging(total) + at = self._staged + chunk = self._staging[at:at + total] + chunk[:, glcanon_bake.STAGE_LINE] = rows[index, COL_LINE] + chunk[:, glcanon_bake.STAGE_P1] = np.where(returning, ends[index], + chain[index]) + chunk[:, glcanon_bake.STAGE_P2] = np.where(returning, chain[index], + ends[index]) + chunk[:, glcanon_bake.STAGE_FEEDRATE] = np.where( + traversing, 0.0, rows[index, COL_FEEDRATE] / 60.) + chunk[:, glcanon_bake.STAGE_OFFSET] = (self.xo, self.yo, self.zo) + self._pending_kinds.extend( + np.where(traversing, CAT_TRAVERSE, CAT_FEED).astype(np.uint8) + .tobytes()) + self._staged = at + total + if self._staged >= rs274.glcanon.FILL_BATCH: + self._flush_moves() + + def _offsets(self, prefix): + return np.array([getattr(self, prefix + axis) for axis in _AXES], + dtype=np.float64) + + # -- one non-move row -------------------------------------------------- + + def _batch_event(self, row, kind): + """Replay one batched event exactly where it sits in the stream. + + Delegates to the canon's own methods rather than reimplementing them: + these are the same calls the C module would have made, and going through + them keeps the suppression rules (dwell and M1xx are dropped while + hidden, tool changes and tool offsets are not) as the one statement of + those rules rather than two. ``lineno`` is taken from the row, since a + batched event delivers no ``next_line``. + """ + self.lineno = int(row[COL_LINE]) + if kind == KIND_DWELL: + self.dwell(float(row[2])) + elif kind == KIND_M1XX: + self.user_defined_function(int(row[2]), float(row[3]), + float(row[4])) + elif kind == KIND_CHANGE_TOOL: + self.change_tool(int(row[2])) + elif kind == KIND_TOOL_OFFSET: + self.tool_offset(*row[COL_AXES].tolist()) + else: + raise ValueError("move_batch: unknown row kind %d" % kind) + + def _record_dwell(self, color): + """As ``GLCanon._record_dwell``, but the plane comes from ``set_plane``. + + The per-move canon reads ``self.state.plane`` - the snapshot the + ``next_line`` for the dwell's own line carried. A batched dwell delivers + no ``next_line``, so that snapshot can predate the dwell by several + lines and hold the wrong plane. ``self.plane`` is what ``set_plane`` + last set, and ``set_plane`` is still forwarded (and flushes first), so + it is current for every row in the batch. + """ + plane = _PLANE_INDEX.get(self.plane, 0) + self.dwells.append((self.lineno, color, self.lo[0], self.lo[1], + self.lo[2], plane)) + self._flush_moves() + self._program_geometry.mark_dwell(self.lineno, self.lo, color, plane) + + +class BatchGLCanon(MoveBatchMixin, GLCanon): + """``GLCanon`` that opts into the move-batch protocol. + + Produces the same program record, dwell table, tool list and final state as + ``GLCanon`` on the same file - see the equality test - so a GUI can swap one + for the other wherever numpy is importable. + """ diff --git a/src/emc/rs274ngc/gcodemodule.cc b/src/emc/rs274ngc/gcodemodule.cc index 256e5acfefc..06e7c8c7c22 100644 --- a/src/emc/rs274ngc/gcodemodule.cc +++ b/src/emc/rs274ngc/gcodemodule.cc @@ -196,13 +196,253 @@ static InterpBase *pinterp; #define callmethod(o, m, f, ...) PyObject_CallMethod((o), (char*)(m), (char*)(f), ## __VA_ARGS__) +// --------------------------------------------------------------------------- +// The move-batch protocol +// --------------------------------------------------------------------------- +// +// An *opt-in* alternative to the per-event callback protocol, for consumers +// that would rather receive a million moves as a few numpy-shaped blocks than +// as a million Python calls. A canon object opts in by setting a truthy +// `use_move_batches` attribute and providing a callable `move_batch`; both are +// read once, in parse_file, before any interpretation. Everything below is +// inert when the flag is absent, and the legacy callback sequence is then +// byte-for-byte what it always was. +// +// In batch mode the canon functions listed under `MoveBatch::Kind` do not call +// Python at all. Each appends one fixed-width row of 13 float64s to a C-owned +// buffer: +// +// [kind, line_number, x, y, z, a, b, c, u, v, w, feedrate, spindle_speed] +// +// Move rows (kinds 0-3) carry exactly the axis values the legacy call would +// have passed - after the `_pos_*` update and after the metric division - so a +// consumer reproduces the legacy geometry bit for bit. Rigid-tap rows carry +// x,y,z and zeros for a..w, as the legacy `rigid_tap` callback did. +// +// Non-move rows keep the row width by carrying their payload in the axis +// slots, zeros elsewhere: +// +// dwell (4) seconds in x +// m1xx (5) function index, P, Q in x, y, z +// change_tool (6) tool number in x +// tool_offset (7) the nine offsets in x..w +// +// `feedrate` is the last SET_FEED_RATE value, post-conversion, so a consumer +// never has to correlate rows with a separate rate callback; it starts at 60.0 +// because GLCanon starts at feedrate 1 (= rate/60). `spindle_speed` is the last +// SET_SPINDLE_SPEED for spindle 0 - data the legacy protocol never delivered, +// since that canon call is an empty stub - carried here so an S word costs +// nothing and never fragments a batch. +// +// Ordering: the buffer is flushed before *any* still-forwarded Python callback, +// which is guaranteed by flushing at the top of maybe_new_line() - see the +// invariant comment there - and also when full, before the periodic +// check_abort(), and at end of parse. What deliberately does NOT flush is +// anything whose effect the rows already carry: the batched events (a G81 cycle +// emits a DWELL per hole, and flushing on those would shred a 100k-hole file +// into four-row batches) and SET_FEED_RATE / SET_SPINDLE_SPEED (both travel in +// the row's own columns; CAM output with adaptive feed emits an F word every +// few moves, and flushing there made batch mode slower than the protocol it +// replaces - see SET_FEED_RATE). +// +// Lifetime: rows are delivered as a read-only memoryview over the buffer, valid +// only for the duration of the move_batch call - the consumer must copy or +// fully consume it before returning. The buffer is allocated once and never +// freed or reallocated, so a consumer that illegally retains the view reads +// stale numbers rather than freed memory. +// +// Ownership: like every other piece of canon state in this file (`callback`, +// `pinterp`, `metric`, `_pos_*`), the buffer is per-process, not per-parse - +// there is exactly one copy, since this translation unit is linked into +// lib/python/gcode.so and nothing else. `interp_from_shlib` swaps the +// *interpreter*, not the canon, so an alternate interpreter still appends here. +// What the batch protocol adds is a way for that to fail *quietly*: a second +// parse_file entered while one is in flight (gcode.parse is not reentrant, and +// AXIS's check_abort pumps the Tk event loop) would re-arm the buffer for a +// different canon, and the outer parse's rows would then be delivered to the +// inner parse's consumer. Hence `owner_`: the canon the buffer was armed for, +// compared - never dereferenced - on every append and flush, so a mismatch +// raises instead of misdelivering. +// +// Opting a GUI in is a separate change; nothing in tree sets the flag yet. + +class MoveBatch { +public: + // Column 0 of a row. Values are protocol, not implementation detail: a + // consumer switches on them, so they may be appended to but never + // renumbered. Kinds 0-3 are moves; 4-7 carry the payloads listed above. + enum Kind : int { + Traverse = 0, + Feed = 1, + Probe = 2, + RigidTap = 3, + Dwell = 4, + M1xx = 5, + ChangeTool = 6, + ToolOffset = 7, + }; + + static constexpr int ROW = 13; // float64s per row, per the layout above + // Rows per delivery: 1.7 MB of buffer, and the size the consumer's + // per-batch numpy temporaries are proportional to. Measured on 500k moves + // (aarch64 dev container): 65536 costs ~32 MB more peak RSS than the + // per-move canon for no gain, while 16384 and 4096 both land at the + // per-move canon's peak exactly and run marginally faster. 16384 is the + // larger of the two that costs nothing, leaving headroom for a consumer + // whose per-batch overhead is higher than the reference one's. + static constexpr int CAP = 16384; + + // Read the opt-in off `canon` and, if it is set, make the buffer ready for + // it. Returns false with a Python exception set; true means the parse may + // proceed, in whichever protocol active() now reports. + static bool arm(PyObject *canon) { + owner_ = nullptr; + count_ = 0; + rate_ = 60.0; + rate_seen_ = false; + speed_ = 0.0; + PyObject *flag = PyObject_GetAttrString(canon, "use_move_batches"); + if(!flag) { + if(!PyErr_ExceptionMatches(PyExc_AttributeError)) return false; + PyErr_Clear(); // no attribute: the legacy protocol + return true; + } + int opted_in = PyObject_IsTrue(flag); + Py_DECREF(flag); + if(opted_in < 0) return false; + if(!opted_in) return true; + // Fail fast rather than fall back: a canon that asked for batches and + // silently got per-move callbacks would look like it worked and quietly + // build a different program. + PyObject *consumer = PyObject_GetAttrString(canon, "move_batch"); + bool usable = consumer && PyCallable_Check(consumer); + Py_XDECREF(consumer); + if(!usable) { + PyErr_Clear(); + PyErr_SetString(PyExc_TypeError, + "parse: canon sets use_move_batches but has no callable " + "move_batch"); + return false; + } + if(!buf_) { + buf_ = (double*)malloc((size_t)CAP * ROW * sizeof(double)); + if(!buf_) { PyErr_NoMemory(); return false; } + } + owner_ = canon; + return true; + } + + static bool active() { return owner_ != nullptr; } + + static void append(Kind kind, int line_number, + double x, double y, double z, + double a, double b, double c, + double u, double v, double w) { + if(!owned()) return; + double *row = buf_ + (size_t)count_ * ROW; + row[0] = static_cast(kind); + row[1] = line_number; + row[2] = x; row[3] = y; row[4] = z; + row[5] = a; row[6] = b; row[7] = c; + row[8] = u; row[9] = v; row[10] = w; + row[11] = rate_; + row[12] = speed_; + count_ ++; + // No next_line is delivered for a batched row, but the error line the + // parse reports must still advance with it. + last_sequence_number = line_number; + if(count_ >= CAP) flush(); + } + + static void flush() { + if(!active()) return; + if(count_ == 0) return; + // Never call into Python with an exception pending: the consumer would + // be handed a broken interpreter state, and the error we already have + // is the one worth reporting. + if(interp_error) return; + if(!owned()) return; + int n = count_; + // Reset before the call, not after: if move_batch raises, these rows + // have still been handed over once, and re-delivering them from a later + // flush would duplicate them in the consumer's program. + count_ = 0; + PyObject *view = PyMemoryView_FromMemory((char*)buf_, + (Py_ssize_t)n * ROW * sizeof(double), PyBUF_READ); + if(!view) { interp_error ++; return; } + PyObject *result = callmethod(callback, "move_batch", "O", view); + Py_DECREF(view); + if(result == NULL) interp_error ++; + Py_XDECREF(result); + } + + // Record the rate the following rows will carry, and answer whether it + // actually moved. The interpreter reports an F word whether or not it + // changes anything - interp_execute.cc branches on `block->f_flag` alone, + // with no comparison against settings->feed_rate - so CAM output that + // repeats the same `F600` on every line calls in here once per move. In + // batch mode there is nothing to say about a rate that did not change: the + // value already reached the consumer in every row's feedrate column. The + // first call of a parse always counts as a change, so a consumer's own + // starting feed rate is set from the file even when the file opens with the + // same 60.0 this starts at. + static bool feed_rate(double rate) { + if(rate == rate_ && rate_seen_) return false; + rate_ = rate; + rate_seen_ = true; + return true; + } + + // Tracked whether or not batch mode is on - one store, and in legacy mode + // nothing ever reads it. There is no callback to suppress here: this canon + // call has never forwarded anything. + static void spindle_speed(double rpm) { speed_ = rpm; } + +private: + MoveBatch() = delete; + + // Is the buffer still the one armed for the canon being parsed into? Only + // a re-entered parse_file can make this false; say so rather than deliver + // one canon's moves to another. + static bool owned() { + if(owner_ == callback) return true; + PyErr_SetString(PyExc_RuntimeError, + "gcode.parse: the move batch belongs to a different canon - " + "gcode.parse was re-entered"); + interp_error ++; + return false; + } + + static inline PyObject *owner_ = nullptr; // compared, never dereferenced + static inline double *buf_ = nullptr; + static inline int count_ = 0; + static inline double rate_ = 60.0; + static inline bool rate_seen_ = false; + static inline double speed_ = 0.0; +}; + +// The `next_line` delivery guard, split off from last_sequence_number: batched +// rows advance that one (parse_file returns it, and error reporting reads it) +// without a next_line having been delivered, so a still-forwarded callback +// later on the same line must not be mistaken for a repeat. The two move in +// lockstep in legacy mode, where nothing but delivery touches either. +static int last_delivered_sequence_number; + static void maybe_new_line(int sequence_number); static void maybe_new_line(); -static void maybe_new_line(int sequence_number) { +// The line number for a batched event whose canon function is not given one. +static int batch_line_number() { + return pinterp ? pinterp->sequence_number() : last_sequence_number; +} + +// Deliver next_line, without flushing. Split out for SET_FEED_RATE, which is +// the one forwarder whose state the batch rows already carry - see there. In +// legacy mode this *is* maybe_new_line(), since the flush is a no-op. +static void deliver_new_line(int sequence_number) { if(!pinterp) return; if(interp_error) return; - if(sequence_number == last_sequence_number) + if(sequence_number == last_delivered_sequence_number) return; LineCode *new_line_code = (LineCode*)(PyObject_New(LineCode, &LineCodeType)); @@ -211,13 +451,26 @@ static void maybe_new_line(int sequence_number) { pinterp->active_m_codes(new_line_code->mcodes); new_line_code->gcodes[0] = sequence_number; last_sequence_number = sequence_number; - PyObject *result = + last_delivered_sequence_number = sequence_number; + PyObject *result = callmethod(callback, "next_line", "O", new_line_code); Py_DECREF(new_line_code); if(result == NULL) interp_error ++; Py_XDECREF(result); } +static void maybe_new_line(int sequence_number) { + // INVARIANT: every canon function that forwards a Python callback calls + // maybe_new_line() first, and this flush is what makes that the batch + // protocol's ordering guarantee - pending rows are delivered before the + // callback that would change the state they were produced under. A new + // forwarder added without this call would silently deliver its callback + // ahead of moves that preceded it; the legacy-vs-batch equality test is + // the tripwire for that. The one deliberate exception is SET_FEED_RATE. + MoveBatch::flush(); + deliver_new_line(sequence_number); +} + static void maybe_new_line() { if(!pinterp) return; maybe_new_line(pinterp->sequence_number()); @@ -362,6 +615,11 @@ void STRAIGHT_FEED(int line_number, _pos_a=a; _pos_b=b; _pos_c=c; _pos_u=u; _pos_v=v; _pos_w=w; if(metric) { x /= 25.4; y /= 25.4; z /= 25.4; u /= 25.4; v /= 25.4; w /= 25.4; } + if(MoveBatch::active()) { + if(interp_error) return; + MoveBatch::append(MoveBatch::Feed, line_number, x, y, z, a, b, c, u, v, w); + return; + } maybe_new_line(line_number); if(interp_error) return; PyObject *result = @@ -379,6 +637,11 @@ void STRAIGHT_TRAVERSE(int line_number, _pos_a=a; _pos_b=b; _pos_c=c; _pos_u=u; _pos_v=v; _pos_w=w; if(metric) { x /= 25.4; y /= 25.4; z /= 25.4; u /= 25.4; v /= 25.4; w /= 25.4; } + if(MoveBatch::active()) { + if(interp_error) return; + MoveBatch::append(MoveBatch::Traverse, line_number, x, y, z, a, b, c, u, v, w); + return; + } maybe_new_line(line_number); if(interp_error) return; PyObject *result = @@ -456,9 +719,15 @@ void SET_FEED_MODE(int /*spindle*/, int /*mode*/) { } void CHANGE_TOOL() { + if(MoveBatch::active()) { + if(interp_error) return; + MoveBatch::append(MoveBatch::ChangeTool, batch_line_number(), + selected_tool, 0, 0, 0, 0, 0, 0, 0, 0); + return; + } maybe_new_line(); if(interp_error) return; - PyObject *result = + PyObject *result = callmethod(callback, "change_tool", "i", selected_tool); if(result == NULL) interp_error ++; Py_XDECREF(result); @@ -479,9 +748,30 @@ void RELOAD_TOOLDATA(void) { * time feed wrong anyway.. */ void SET_FEED_RATE(double rate) { - maybe_new_line(); - if(interp_error) return; if(metric) rate /= 25.4; + if(MoveBatch::active()) { + // Two departures here, both because the rate is already in every row. + // + // It does not flush. There is nothing a batch boundary would tell the + // consumer that the feedrate column has not. Cutting one is expensive + // on real files - CAM output with adaptive feed emits an F word every + // few moves, which would deliver the program in batches of a handful of + // rows and make batch mode *slower* than the per-move protocol it + // replaces (measured on 500k moves: 0.59x with the flush, 1.85x + // without). + // + // And it does not forward at all unless the rate moved - see + // MoveBatch::feed_rate for why the interpreter calls this so often. + // + // Neither affects arcs: ARC_FEED still flushes, and the arc path stages + // its segments at arc time with the rate current then, which is this + // one whether or not the callback that set it was suppressed. + if(!MoveBatch::feed_rate(rate)) return; + deliver_new_line(batch_line_number()); + } else { + maybe_new_line(); + } + if(interp_error) return; PyObject *result = callmethod(callback, "set_feed_rate", "f", rate); if(result == NULL) interp_error ++; @@ -489,7 +779,14 @@ void SET_FEED_RATE(double rate) { } void DWELL(double time) { - maybe_new_line(); + if(MoveBatch::active()) { + if(interp_error) return; + // Appended, not flushed: a G81/G82 cycle emits one of these per hole. + MoveBatch::append(MoveBatch::Dwell, batch_line_number(), + time, 0, 0, 0, 0, 0, 0, 0, 0); + return; + } + maybe_new_line(); if(interp_error) return; PyObject *result = callmethod(callback, "dwell", "f", time); @@ -526,6 +823,22 @@ void SET_TOOL_TABLE_ENTRY(int /*pocket*/, int /*toolno*/, const EmcPose& /*offse void USE_TOOL_LENGTH_OFFSET(const EmcPose& offset) { tool_offset = offset; + if(MoveBatch::active()) { + if(interp_error) return; + if(metric) { + MoveBatch::append(MoveBatch::ToolOffset, batch_line_number(), + offset.tran.x / 25.4, offset.tran.y / 25.4, + offset.tran.z / 25.4, + offset.a, offset.b, offset.c, + offset.u / 25.4, offset.v / 25.4, offset.w / 25.4); + } else { + MoveBatch::append(MoveBatch::ToolOffset, batch_line_number(), + offset.tran.x, offset.tran.y, offset.tran.z, + offset.a, offset.b, offset.c, + offset.u, offset.v, offset.w); + } + return; + } maybe_new_line(); if(interp_error) return; PyObject *result; @@ -555,7 +868,12 @@ void START_SPINDLE_COUNTERCLOCKWISE(int /*spindle*/, int /*wait_for_at_speed*/) void START_SPINDLE_CLOCKWISE(int /*spindle*/, int /*wait_for_at_speed*/) {} void SET_SPINDLE_MODE(int /*spindle*/, double) {} void STOP_SPINDLE_TURNING(int /*spindle*/, int /*wait_for_at_speed*/) {} -void SET_SPINDLE_SPEED(int /*spindle*/, double /*rpm*/) {} +// Forwards nothing - it never has - but the value is worth carrying: recording +// it here costs one store and gives the batch rows a spindle-speed column the +// per-move protocol never had, without adding a callback or a flush point. +void SET_SPINDLE_SPEED(int spindle, double rpm) { + if(spindle == 0) MoveBatch::spindle_speed(rpm); +} void ORIENT_SPINDLE(int /*spindle*/, double /*d*/, int /*i*/) {} void WAIT_SPINDLE_ORIENT_COMPLETE(int /*s*/, double /*timeout*/) {} void PROGRAM_STOP() {} @@ -624,6 +942,11 @@ void STRAIGHT_PROBE(int line_number, _pos_a=a; _pos_b=b; _pos_c=c; _pos_u=u; _pos_v=v; _pos_w=w; if(metric) { x /= 25.4; y /= 25.4; z /= 25.4; u /= 25.4; v /= 25.4; w /= 25.4; } + if(MoveBatch::active()) { + if(interp_error) return; + MoveBatch::append(MoveBatch::Probe, line_number, x, y, z, a, b, c, u, v, w); + return; + } maybe_new_line(line_number); if(interp_error) return; PyObject *result = @@ -636,6 +959,14 @@ void STRAIGHT_PROBE(int line_number, void RIGID_TAP(int line_number, double x, double y, double z, double /*scale*/) { if(metric) { x /= 25.4; y /= 25.4; z /= 25.4; } + if(MoveBatch::active()) { + if(interp_error) return; + // a..w are zero, exactly the arguments the legacy `rigid_tap` callback + // did not have; the consumer joins x,y,z to the chain point's a..w as + // GLCanon.rigid_tap does. + MoveBatch::append(MoveBatch::RigidTap, line_number, x, y, z, 0, 0, 0, 0, 0, 0); + return; + } maybe_new_line(line_number); if(interp_error) return; PyObject *result = @@ -707,6 +1038,11 @@ int WAIT(int /*index*/, int /*input_type*/, int /*wait_type*/, double /*timeout* static void user_defined_function(int num, double arg1, double arg2) { if(interp_error) return; + if(MoveBatch::active()) { + MoveBatch::append(MoveBatch::M1xx, batch_line_number(), + num, arg1, arg2, 0, 0, 0, 0, 0, 0); + return; + } maybe_new_line(); PyObject *result = callmethod(callback, "user_defined_function", @@ -873,6 +1209,13 @@ static PyObject *parse_file(PyObject * /*self*/, PyObject *args) { return NULL; } + // Protocol selection, once, before anything is interpreted: a mode that + // could flip mid-parse would leave the consumer's program half in each + // protocol, and a per-call attribute check would cost more than batching + // saves. + if(!MoveBatch::arm(callback)) return NULL; + last_delivered_sequence_number = -1; + if(pinterp) { delete pinterp; pinterp = NULL; @@ -929,6 +1272,11 @@ static PyObject *parse_file(PyObject * /*self*/, PyObject *args) { result = pinterp->read(); gettimeofday(&t1, NULL); if(t1.tv_sec > t0.tv_sec + wait) { + // Bounds how stale a batch consumer's progress can get, and keeps + // check_abort() - which pumps AXIS's event loop - from running with + // a pending exception raised by the flush. + MoveBatch::flush(); + if(interp_error) break; if(check_abort()) return NULL; t0 = t1; } From 6c5b8e2640a81eaf83b3c7c959a732cb52b7d031 Mon Sep 17 00:00:00 2001 From: Alexey Presnyakov <309782758+alex-pres@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:17:28 +0400 Subject: [PATCH 3/8] glnav: finish the de-GL conversion glnav no longer imports OpenGL at module level, so the camera and its matrix helpers import and test on a host with no GL stack. The fixed-function first-expose lighting moves to an rs274.OpenGLTk.Opengl override that owns the compatibility context, the dead display-list font path goes, and scale() no longer raises NameError on an undefined name. --- lib/python/glnav.py | 132 ++++------------------------------- lib/python/rs274/OpenGLTk.py | 22 ++++++ 2 files changed, 35 insertions(+), 119 deletions(-) diff --git a/lib/python/glnav.py b/lib/python/glnav.py index 161d57d8a89..9c624b4aeca 100644 --- a/lib/python/glnav.py +++ b/lib/python/glnav.py @@ -1,12 +1,7 @@ import math -import array, itertools -import sys import numpy as np -from OpenGL.GL import * -from OpenGL.GLU import * - def use_pango_font(font, start, count, will_call_prepost=False): """Build a glyph-atlas font for the OpenGL 3.3 core overlay renderer. @@ -21,101 +16,6 @@ def use_pango_font(font, start, count, will_call_prepost=False): return atlas, atlas.char_width, atlas.line_space -def _legacy_use_pango_font(font, start, count, will_call_prepost=False): - import gi - gi.require_version('Pango','1.0') - gi.require_version('PangoCairo','1.0') - from gi.repository import Pango - from gi.repository import PangoCairo - #from gi.repository import Cairo as cairo - import cairo - - fontDesc = Pango.FontDescription(font) - a = array.array('b', itertools.repeat(0, 256*256)) - surface = cairo.ImageSurface.create_for_data(a, 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() - font = fontmap.load_font(fontmap.create_context(), fontDesc) - layout.set_font_description(fontDesc) - metrics = font.get_metrics() - descent = metrics.get_descent() - d = descent / Pango.SCALE - linespace = metrics.get_ascent() + metrics.get_descent() - width = metrics.get_approximate_char_width() - - glPushClientAttrib(GL_CLIENT_PIXEL_STORE_BIT) - glPixelStorei(GL_UNPACK_SWAP_BYTES, 0) - glPixelStorei(GL_UNPACK_LSB_FIRST, 1) - glPixelStorei(GL_UNPACK_ROW_LENGTH, 256) - glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, 256) - glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0) - glPixelStorei(GL_UNPACK_SKIP_ROWS, 0) - glPixelStorei(GL_UNPACK_SKIP_IMAGES, 0) - glPixelStorei(GL_UNPACK_ALIGNMENT, 1) - glPixelZoom(1, -1) - - base = glGenLists(count) - for i in range(count): - ch = chr(start+i) - layout.set_text(ch, -1) - w, h = layout.get_size() - context.save() - context.new_path() - context.rectangle(0, 0, 256, 256) - context.set_source_rgba(0., 0., 0., 0.) - context.set_operator (cairo.OPERATOR_SOURCE) - context.paint() - context.restore() - - context.save() - context.set_source_rgba(1., 1., 1., 1.) - context.set_operator (cairo.OPERATOR_SOURCE) - context.move_to(0, 0) - PangoCairo.update_context(context,pango_context) - PangoCairo.show_layout(context,layout) - context.restore() - w, h = int(w / Pango.SCALE), int(h / Pango.SCALE) - glNewList(base+i, GL_COMPILE) - #workaround: https://github.com/LinuxCNC/linuxcnc/pull/2446 - try: - glBitmap(1, 0, 0, 0, 0, h-d, bytearray([0]*4)) - except OpenGL.error.GLError: - glBitmap(1, 1, 0, 0, 0, h-d, bytearray([0]*4)) - - - #glDrawPixels(0, 0, 0, 0, 0, h-d, ''); - if not will_call_prepost: - pango_font_pre() - if w and h: - try: - pass - glDrawPixels(w, h, GL_LUMINANCE, GL_UNSIGNED_BYTE, a.tobytes()) - except Exception as e: - print("glnav Exception ",e) - #workaround: https://github.com/LinuxCNC/linuxcnc/pull/2446 - try: - glBitmap(1, 0, 0, 0, w, -h+d, bytearray([0]*4)) - except OpenGL.error.GLError: - glBitmap(1, 1, 0, 0, w, -h+d, bytearray([0]*4)) - - if not will_call_prepost: - pango_font_post() - glEndList() - - glPopClientAttrib() - return base, int(width / Pango.SCALE), int(linespace / Pango.SCALE) - - -def pango_font_pre(rgba=(1., 1., 0., 1.)): - glPushAttrib(GL_COLOR_BUFFER_BIT) - glEnable(GL_BLEND) - glBlendFunc(GL_ONE, GL_ONE) - -def pango_font_post(): - glPopAttrib() - def identity_matrix(): return np.identity(4, dtype=np.float64) @@ -273,22 +173,14 @@ def __init__(self): self._totaly = 0.0 self.modelview = identity_matrix() - # This should almost certainly be part of some derived class. - # But I have put it here for convenience. def basic_lighting(self): """\ - Set up some basic lighting (single infinite light source). - - Also switch on the depth buffer.""" - - self.activate() - glLightfv(GL_LIGHT0, GL_POSITION, (1, -1, 1, 0)) - glLightfv(GL_LIGHT0, GL_AMBIENT, (.4, .4, .4, 1)) - glLightfv(GL_LIGHT0, GL_DIFFUSE, (.6, .6, .6, 1)) - glEnable(GL_LIGHTING) - glEnable(GL_LIGHT0) - glDepthFunc(GL_LESS) - glEnable(GL_DEPTH_TEST) + Reset the camera for the first expose. + + Widgets that own a fixed-function context (rs274.OpenGLTk.Opengl, + vismach) override this to set their lighting and depth state up first; + the camera itself has no GL state to configure.""" + self.modelview = identity_matrix() @@ -364,7 +256,7 @@ def scale(self, x, y): Dragging up zooms in, while dragging down zooms out """ - scale = 1 - 0.01 * (event.y - self.ymouse) + scale = 1 - 0.01 * (y - self.ymouse) # do some sanity checks, scale no more than # 1:1000 on any given click+drag if scale < 0.001: @@ -384,8 +276,9 @@ def rotate(self, x, y): self.perspective = True glRotateScene(self, 0.5, self.xcenter, self.ycenter, self.zcenter, x, y, self.xmouse, self.ymouse) if self.is_lathe(): - glRotatef(90, 1, 0, 0) - glRotatef(90, 0, 1, 0) + self.modelview = multiply(self.modelview, + rotation_matrix(90, 1, 0, 0), + rotation_matrix(90, 0, 1, 0)) self._redraw() self.recordMouse(x, y) @@ -557,8 +450,9 @@ def set_view_p(self): self.lon = 335 glRotateScene(self, 1.0, mid[0], mid[1], mid[2], 0, 0, 0, 0) if self.is_lathe(): - glRotatef(90, 1, 0, 0) - glRotatef(90, 0, 1, 0) + self.modelview = multiply(self.modelview, + rotation_matrix(90, 1, 0, 0), + rotation_matrix(90, 0, 1, 0)) self._redraw() # vim:ts=8:sts=4:sw=4:et: diff --git a/lib/python/rs274/OpenGLTk.py b/lib/python/rs274/OpenGLTk.py index e0f1a9fceaf..aeb6f1b532c 100755 --- a/lib/python/rs274/OpenGLTk.py +++ b/lib/python/rs274/OpenGLTk.py @@ -226,6 +226,28 @@ def activate(self): self.tk.call(self._w, 'makecurrent') + def basic_lighting(self): + """\ + Set up some basic lighting (single infinite light source). + + Also switch on the depth buffer. + + The fixed-function calls live here rather than in glnav because this + widget is the one that owns a compatibility context; glnav's base + method is the GL-free camera reset, invoked after the GL setup to keep + the legacy order (activate, GL state, camera reset).""" + + self.activate() + glLightfv(GL_LIGHT0, GL_POSITION, (1, -1, 1, 0)) + glLightfv(GL_LIGHT0, GL_AMBIENT, (.4, .4, .4, 1)) + glLightfv(GL_LIGHT0, GL_DIFFUSE, (.6, .6, .6, 1)) + glEnable(GL_LIGHTING) + glEnable(GL_LIGHT0) + glDepthFunc(GL_LESS) + glEnable(GL_DEPTH_TEST) + super().basic_lighting() + + def tkHandlePick(self, event): """Handle a pick on the scene.""" From 9924a340f32f97af6f6703e82f5f747a8f4ccbf4 Mon Sep 17 00:00:00 2001 From: Alexey Presnyakov <309782758+alex-pres@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:17:48 +0400 Subject: [PATCH 4/8] glcanon: hand the program arrays to GL without copying them program_parts copied every plane array so the foam Z offset could be added without mutating the canon array - even when the offset was 0.0, which is every non-foam config. The offset is a rigid Z translation, so it folds into the recorded pass MVP instead, at one site covering the colour, id and override passes. Naming geometry.index also built that lazy property on the load path, for something only the highlight reads; it is resolved on first draw now. Measured at 1M vertices: 16.0 MB copied -> 0, 32.0 MB -> 0 in foam. At the 10M size the buffer layout is sized against, 160 MB of copy and 227 MB of index transient leave the window in which the driver is asked for 240 MB. The VBO itself is unchanged at 24 bytes a move, 40 in foam. --- lib/python/rs274/glcanon_bake.py | 62 +++++++++++++++++++++++++------- lib/python/rs274/glcanon_gl.py | 61 ++++++++++++++++++++++++++++--- 2 files changed, 105 insertions(+), 18 deletions(-) diff --git a/lib/python/rs274/glcanon_bake.py b/lib/python/rs274/glcanon_bake.py index 29c2d7c38c5..203dbe100ba 100644 --- a/lib/python/rs274/glcanon_bake.py +++ b/lib/python/rs274/glcanon_bake.py @@ -306,6 +306,7 @@ def _unrotate_xy(pts: Float64Points, rotation_xy: float, class ProgramGeometry: """The parsed program, as arrays. The authoritative record of what it is. + It is the program record and the ready-to-go GPU's source data at once. Owned by :class:`rs274.glcanon.GLCanon` and filled during the parse, so a canon driven with no GL context still holds the complete program: every @@ -428,6 +429,27 @@ def n_moves(self) -> int: """Moves reported, as opposed to vertices written.""" return self._moves + @property + def capacity(self) -> int: + """Vertices the arrays can hold before the next :meth:`_reserve`. + + Reported rather than inferred because the doubling growth means it is + anywhere between :attr:`n_vertices` and twice it. Note that the + difference is address space and not resident memory: :meth:`_reserve` + allocates with ``np.empty`` and only the written prefix is ever + touched, so the unused tail is never faulted in. + """ + return len(self._attrs) + + @property + def nbytes(self) -> int: + """Bytes the position and attribute arrays span, slack included. + + Address span, not resident memory - see :attr:`capacity`. Reading this + as RAM overstates a grown array by up to a factor of two. + """ + return int(sum(a.nbytes for a in self._planes) + self._attrs.nbytes) + def plane_array(self, plane: int = 0) -> npt.NDArray[Any]: """The ``(N,)`` :data:`PLANE_DTYPE` array for one drawn plane.""" return self._planes[plane][:self._n] @@ -990,6 +1012,12 @@ def dwell_marker_part(geometry: "ProgramGeometry", is_lathe: bool = False, the GEOMETRY string and the rotation offsets as arguments and applied neither. In foam the program is drawn on two planes and so is each marker. + ``offsets`` are reported, not applied, exactly as they are for the + trajectory. The markers are small enough that a translation here would cost + nothing; they follow the same rule so that the offset has one home. Two + places applying it is how a marker and the path it marks come to sit at + different heights after a later change to only one of them. + Both planes use the marker's own colour: unlike the trajectory, which has ``straight_feed_xy``/``_uv`` variants, the colour table has no per-plane entry for a dwell, and the canon stores the resolved colour rather than @@ -1036,13 +1064,13 @@ def dwell_marker_part(geometry: "ProgramGeometry", is_lathe: bool = False, arr = np.zeros(n, dtype=PLANE_DTYPE) if n: arr['pos'] = np.asarray(plane_points[i], dtype=np.float32) - arr['pos'][:, 2] += offsets[i] if i < len(offsets) else 0.0 planes.append(arr) padded = list(entries) + [(0.0, 0.0, 0.0, 1.0)] * (PALETTE_SIZE - len(entries)) return {"name": "dwell", "kind": "program_array", "planes": planes, "attrs": attrs, "palettes": [padded] * n_planes, + "plane_offsets": tuple(offsets[:n_planes]), "mode": MODE_LINES, # No record kinds here, and no rapid to hide or dash: every entry # is a colour, so the whole palette is drawable. @@ -1061,28 +1089,36 @@ def program_parts(geometry: "ProgramGeometry", colors: dict[str, Any], Two: the trajectory - one draw over a contiguous range, per drawn plane, off one shared attribute array - and the dwell markers. - The planes' Z offsets are applied here rather than in the fill, because - ``foam_z``/``foam_w`` can still move while the program is being parsed (an - ``(AXIS,XY_Z_POS)`` comment sets them), and this runs after it. + The planes' Z offsets are *reported* here rather than applied, and neither + the fill nor this stores them in a position. ``foam_z``/``foam_w`` say + where a plane is drawn, not what the program is: they can still move while + the program is being parsed (an ``(AXIS,XY_Z_POS)`` comment sets them), and + they are a rigid translation, so they belong in the draw's matrix. The + buffer applies them once for every pass it can be drawn in. + + That is also what makes this function free of copies. Every array here is + the one the fill wrote, handed to ``glBufferData`` as a view, so the + process is not holding a second copy of the program at the moment the + driver allocates the first - which on a Pi is the same pool of memory. """ suffixes = ("_xy", "_uv") if is_foam else ("",) offsets = (foam_z, foam_w) if is_foam else (0.0,) - planes = [] - palettes = [] - for i, (suffix, dz) in enumerate(zip(suffixes, offsets)): - arr = geometry.plane_array(i).copy() - if arr.size and dz: - arr['pos'][:, 2] += dz - planes.append(arr) - palettes.append(palette(colors, suffix)) + planes = [geometry.plane_array(i) for i in range(len(offsets))] + palettes = [palette(colors, suffix) for suffix in suffixes] return [ {"name": "program", "kind": "program_array", "planes": planes, "attrs": geometry.attrs, "palettes": palettes, + "plane_offsets": offsets, # The program is the buffer that nominates a dash and a hidden kind, # and both are its rapid code. Every other buffer nominates neither. "dash_cat": KIND_TRAVERSE, "hide_cat": KIND_TRAVERSE, "last_drawn_kind": LAST_DRAWN_KIND, - "mode": MODE_LINE_STRIP, "spans": geometry.index}, + # Passed as a callable, not as the index itself: ``index`` builds on + # first mention, and the only thing that reads it is the highlight. + # Naming it here would build it at load, where its full-length + # temporaries peak a few statements before the driver is asked for the + # buffer - and for a program nobody clicks, never be read at all. + "mode": MODE_LINE_STRIP, "spans": lambda: geometry.index}, dwell_marker_part(geometry, is_lathe, cross, offsets), ] diff --git a/lib/python/rs274/glcanon_gl.py b/lib/python/rs274/glcanon_gl.py index bce4ea0ff72..2e14dd1462f 100644 --- a/lib/python/rs274/glcanon_gl.py +++ b/lib/python/rs274/glcanon_gl.py @@ -1137,7 +1137,8 @@ def upload(self, parts: Sequence[dict[str, Any]]) -> None: last_drawn_kind=part.get("last_drawn_kind", PALETTE_SIZE - 1), mode=primitive_mode(part.get("mode")), - spans=part.get("spans")) + spans=part.get("spans"), + plane_offsets=part.get("plane_offsets", ())) elif kind == "trajectory": empty = np.empty(0, dtype=np.int32) buf.upload(part["verts"], @@ -1412,6 +1413,13 @@ def __init__(self, mode: GLEnum = GL_LINE_STRIP) -> None: #: 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 dashes, 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 @@ -1420,7 +1428,10 @@ def __init__(self, mode: GLEnum = GL_LINE_STRIP) -> None: 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. + #: 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 @@ -1430,7 +1441,8 @@ def upload(self, planes: Sequence[Any], attrs: Any, dash_cat: int = -1, hide_cat: int = -1, last_drawn_kind: int = PALETTE_SIZE - 1, mode: GLEnum | None = None, - spans: Any = 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 @@ -1443,6 +1455,10 @@ def upload(self, planes: Sequence[Any], attrs: Any, 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()) @@ -1489,11 +1505,33 @@ def begin_override(self, renderer: GlCanonRenderer, mvp: Any, """ 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) -> None: """Establish the recorded pass's shader state for one plane.""" if self._pass is None: return - what, renderer, mvp = self._pass[0], self._pass[1], self._pass[2] + 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( @@ -1524,11 +1562,24 @@ def draw(self) -> None: 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``.""" if not self.count or self.spans is None or lineno is None: return - keys, firsts, counts = self.spans + 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: From 46f50d1ea02d0a15d541477a4ee2d9a9267e04c7 Mon Sep 17 00:00:00 2001 From: Alexey Presnyakov <309782758+alex-pres@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:17:49 +0400 Subject: [PATCH 5/8] glcanon: run the preview on OpenGL ES 3.1 as well as 3.3 core Targeting GL 3.3 core alone ruled out the Raspberry Pi 4 the project ships an official image for: Mesa v3d exposes no desktop core profile at all, and its real API is OpenGL ES 3.1. The renderer now targets the intersection of the two - one implementation, not a second. The GLSL version directive is injected at compile time, the pick pass reads a colour attachment because ES forbids reading depth, glMultiDrawArrays becomes a per-span loop, and narrow buffers quad-expand when the driver refuses the line width asked for. Context creation tries 3.3 core first and falls back to GLES 3.1. Desktop rendering is unchanged: 0 differing pixels across all 17 toggle fixtures. --- lib/python/rs274/glcanon.py | 30 +- lib/python/rs274/glcanon_gl.py | 847 +++++++++++++++++++++-- lib/python/rs274/glcanon_scene.py | 33 +- src/emc/usr_intf/axis/extensions/togl.c | 67 +- src/emc/usr_intf/gremlin/gremlin.py | 83 ++- src/emc/usr_intf/gremlin/qt5_graphics.py | 53 +- 6 files changed, 1002 insertions(+), 111 deletions(-) diff --git a/lib/python/rs274/glcanon.py b/lib/python/rs274/glcanon.py index 9a017deb528..bc9128dd623 100644 --- a/lib/python/rs274/glcanon.py +++ b/lib/python/rs274/glcanon.py @@ -751,16 +751,32 @@ def realize(self): glPixelStorei(GL_UNPACK_ALIGNMENT, 1) glEnable(GL_DEPTH_TEST) glDepthFunc(GL_LESS) + self._announce_gl_context() if glcanon_gl.GL_DEBUG: self._log_gl_context() self.initialised = 1 + def _announce_gl_context(self): + """State which API and version the preview actually got, once. + + Unconditional, unlike the debug dump below. Two contexts are now + accepted - OpenGL 3.3 core and OpenGL ES 3.1 - and on a Raspberry Pi + they are visually indistinguishable apart from line width, so a bug + report has to be able to say which one ran without asking the reporter + to install and run eglinfo. One line, at realize, per widget. + """ + import sys + caps = self.renderer.caps + print("preview renderer: %s %s" + % ("OpenGL ES" if caps.is_gles else "OpenGL", + caps.describe()), file=sys.stderr) + def _log_gl_context(self): """Report the created OpenGL context (version/renderer/GLSL) to stderr. - Enabled with GLCANON_GL_DEBUG=1. The preview needs a 3.3 core profile; - this makes the context that was actually obtained visible - e.g. to - confirm 3.3 core on X11/XWayland, or that llvmpipe (software) is in use.""" + Enabled with GLCANON_GL_DEBUG=1. Adds the GLSL version to the line + above, which is the part that differs between the two accepted + contexts (GLSL 330 against GLSL ES 300).""" import sys def s(name): try: @@ -952,6 +968,9 @@ def redraw_perspective(self): w = self.winfo_width() h = self.winfo_height() glViewport(0, 0, w, h) + # Told, not queried: the quad-expanded line path works in pixels and + # needs the drawable size, and this is where GL is given it. + self.renderer.set_viewport(w, h) # Clear the background and depth buffer. glClearColor(*(self.colors['back'] + (0,))) @@ -974,6 +993,9 @@ def redraw_ortho(self): w = self.winfo_width() h = self.winfo_height() glViewport(0, 0, w, h) + # Told, not queried: the quad-expanded line path works in pixels and + # needs the drawable size, and this is where GL is given it. + self.renderer.set_viewport(w, h) # Clear the background and depth buffer. glClearColor(*(self.colors['back'] + (0,))) @@ -1160,7 +1182,7 @@ def frame_context(self) -> glcanon_scene.FrameContext: """ return glcanon_scene.FrameContext( mv=self.mv, prim=self.prim, renderer=self.renderer, - colors=self.colors, + caps=self.renderer.caps, colors=self.colors, stat=self.stat, canon=self.canon, lp=self.lp, geometry=self.get_geometry(), is_lathe=self.is_lathe(), diff --git a/lib/python/rs274/glcanon_gl.py b/lib/python/rs274/glcanon_gl.py index 2e14dd1462f..ac073da9fe6 100644 --- a/lib/python/rs274/glcanon_gl.py +++ b/lib/python/rs274/glcanon_gl.py @@ -1,13 +1,31 @@ -# OpenGL 3.3 core renderer infrastructure for the rs274 G-code preview. +# OpenGL renderer infrastructure for the rs274 G-code preview. # -# This module holds the low-level, reusable GL building blocks for the core- -# profile preview renderer that replaces the legacy fixed-function drawing in +# 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 GLSL 330 line -# shader (position + color + line-number + distance-along-line, driven by a -# single MVP uniform, with shader-side dashing). 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. +# VBO/VAO wrappers, a per-pass glGetError debug check, and the line shader +# (position + color + line-number + distance-along-line, driven by a single +# MVP uniform, with shader-side dashing). 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 @@ -22,6 +40,7 @@ import os from contextlib import contextmanager +from dataclasses import dataclass from typing import Any, Callable, Iterator, Sequence from OpenGL.GL import * @@ -120,6 +139,49 @@ _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 7. +# --------------------------------------------------------------------------- +ATTR_POSITION_B = 5 +ATTR_COLOR_B = 6 +ATTR_DISTANCE_B = 7 + +#: Per-vertex-colour (36-byte) layout, split into the two endpoints. +WIDE_LINE_A_ATTRIBUTES = ( + (ATTR_POSITION, 3, 0), + (ATTR_COLOR, 4, 3 * 4), + (ATTR_DISTANCE, 1, 8 * 4), +) +WIDE_LINE_B_ATTRIBUTES = ( + (ATTR_POSITION_B, 3, 0), + (ATTR_COLOR_B, 4, 3 * 4), + (ATTR_DISTANCE_B, 1, 8 * 4), +) + +#: Program-array plane buffer, split into the two endpoints. +WIDE_PLANE_A_ATTRIBUTES = ( + (ATTR_POSITION, 3, 0), + (ATTR_DISTANCE, 1, 3 * 4), +) +WIDE_PLANE_B_ATTRIBUTES = ( + (ATTR_POSITION_B, 3, 0), + (ATTR_DISTANCE_B, 1, 3 * 4), +) +#: 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. @@ -179,6 +241,133 @@ def check_gl_error(where: str) -> None: 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. + """ + global _ACTIVE_CAPS, _MAX_LINE_WIDTH + _ACTIVE_CAPS = caps + _MAX_LINE_WIDTH = None + + _MAX_LINE_WIDTH: float | None = None @@ -214,10 +403,23 @@ def _try_line_width(w: float) -> bool: 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 + + 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). Probed once, cached.""" - global _MAX_LINE_WIDTH + supports width 1.0 (core profiles guarantee no more, and GLES on Mesa's + v3d grants exactly 1.0). Probed once, 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`. + """ + global _MAX_LINE_WIDTH, _REQUESTED_LINE_WIDTH + _REQUESTED_LINE_WIDTH = max(1.0, float(width)) if _MAX_LINE_WIDTH is None: _MAX_LINE_WIDTH = 3.0 if _try_line_width(3.0) else 1.0 w = max(1.0, min(float(width), _MAX_LINE_WIDTH)) @@ -226,11 +428,62 @@ def set_line_width(width: float) -> None: _try_line_width(1.0) +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 # --------------------------------------------------------------------------- -def compile_shader(source: str, shader_type: GLEnum) -> int: - """Compile one shader stage; raise RuntimeError with the info log on failure.""" + +#: 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. +#: highp also keeps v_distance exact far into a large program, where a mediump +#: dash phase would visibly drift. +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) @@ -271,9 +524,10 @@ def link_program(*shaders: int) -> int: class ShaderProgram: """A linked GLSL program with a cached uniform-location lookup.""" - def __init__(self, vertex_source: str, fragment_source: str) -> None: - vs = compile_shader(vertex_source, GL_VERTEX_SHADER) - fs = compile_shader(fragment_source, GL_FRAGMENT_SHADER) + 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] = {} @@ -379,7 +633,8 @@ def unbind(self) -> None: def configure(self, buffer: GLBuffer, attributes: Sequence[Sequence[int]] = LINE_ATTRIBUTES, - stride: int = VERTEX_STRIDE) -> None: + 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 @@ -387,6 +642,13 @@ def configure(self, buffer: GLBuffer, 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() @@ -396,10 +658,12 @@ def configure(self, buffer: GLBuffer, glEnableVertexAttribArray(location) if gl_type in _INTEGER_ATTRIB_TYPES: glVertexAttribIPointer(location, size, gl_type, stride, - ctypes_offset(offset)) + ctypes_offset(base + offset)) else: glVertexAttribPointer(location, size, gl_type, GL_FALSE, - stride, ctypes_offset(offset)) + stride, ctypes_offset(base + offset)) + if divisor: + glVertexAttribDivisor(location, divisor) self.unbind() def delete(self) -> None: @@ -415,10 +679,9 @@ def ctypes_offset(byte_offset: int) -> Any: # --------------------------------------------------------------------------- -# Line shader (GLSL 330 core) +# Line shader (GL 3.3 core / GLES 3.1 intersection) # --------------------------------------------------------------------------- LINE_VERTEX_SHADER = """ -#version 330 core layout(location = 0) in vec3 in_position; layout(location = 1) in vec4 in_color; layout(location = 2) in float in_lineno; @@ -437,7 +700,6 @@ def ctypes_offset(byte_offset: int) -> Any: """ LINE_FRAGMENT_SHADER = """ -#version 330 core in vec4 v_color; in float v_distance; @@ -459,6 +721,80 @@ def ctypes_offset(byte_offset: int) -> Any: """ +# --------------------------------------------------------------------------- +# 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 = 3) in float in_distance; +layout(location = 5) in vec3 in_position_b; +layout(location = 6) in vec4 in_color_b; +layout(location = 7) in float in_distance_b; + +uniform mat4 u_mvp; + +out vec4 v_color; +out float v_distance; +%(expand)s +void main() { + vec4 ca = u_mvp * vec4(in_position, 1.0); + vec4 cb = u_mvp * vec4(in_position_b, 1.0); + bool at_b = gl_VertexID > 1; + v_color = at_b ? in_color_b : in_color; + v_distance = at_b ? in_distance_b : in_distance; + gl_Position = expand(ca, cb); +} +""" % {"expand": WIDE_LINE_EXPAND} + + class LineProgram: """The line shader plus its default uniform state. @@ -466,10 +802,16 @@ class LineProgram: 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(LINE_VERTEX_SHADER, LINE_FRAGMENT_SHADER) + self.shader = ShaderProgram(self.VERTEX_SHADER, LINE_FRAGMENT_SHADER) def use(self) -> None: self.shader.use() @@ -504,8 +846,21 @@ 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 (GLSL 330 core): the program's own line shader. +# 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 @@ -519,7 +874,6 @@ def delete(self) -> None: # highlight contract - a segment belongs to the source line of its END point. # --------------------------------------------------------------------------- TRAJ_VERTEX_SHADER = """ -#version 330 core layout(location = 0) in vec3 in_position; layout(location = 2) in uint in_packed; // lineno | category << 24 layout(location = 3) in float in_distance; @@ -540,7 +894,6 @@ def delete(self) -> None: # the two-buffer 24-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 = """ -#version 330 core layout(location = 0) in vec3 in_position; layout(location = 3) in float in_distance; layout(location = 4) in uint in_kindtool; @@ -558,7 +911,6 @@ def delete(self) -> None: """ % {"kind_mask": KIND_MASK} TRAJ_FRAGMENT_SHADER = """ -#version 330 core flat in uint v_kind; in float v_distance; @@ -695,7 +1047,6 @@ def delete(self) -> None: TRAJ_PICK_VERTEX_SHADER = """ -#version 330 core layout(location = 0) in vec3 in_position; layout(location = 2) in uint in_packed; @@ -714,7 +1065,6 @@ def delete(self) -> None: # 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 = """ -#version 330 core layout(location = 0) in vec3 in_position; layout(location = 2) in uint in_lineno; layout(location = 4) in uint in_kindtool; @@ -731,16 +1081,63 @@ def delete(self) -> None: } """ % {"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 = """ -#version 330 core 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 -out vec4 frag_color; - +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 @@ -760,8 +1157,9 @@ def delete(self) -> None: 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: @@ -789,12 +1187,55 @@ 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 = 3) in float in_distance; +layout(location = 4) in uint in_kindtool; // the segment's END vertex +layout(location = 5) in vec3 in_position_b; +layout(location = 7) in float in_distance_b; + +uniform mat4 u_mvp; + +flat out uint v_kind; +out float v_distance; +%(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; + v_distance = (gl_VertexID > 1) ? in_distance_b : in_distance; + gl_Position = expand(ca, cb); +} +""" % {"expand": WIDE_LINE_EXPAND, "kind_mask": KIND_MASK} + + class ProgramArrayProgram(TrajectoryProgram): """:class:`TrajectoryProgram` over the program array's 24-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.""" @@ -802,7 +1243,7 @@ class ProgramArrayPickProgram(TrajectoryPickProgram): # --------------------------------------------------------------------------- -# Pick shader (GLSL 330 core) for the per-vertex-colour layout: draw pickable +# 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 @@ -815,7 +1256,6 @@ class ProgramArrayPickProgram(TrajectoryPickProgram): # rather than be deleted along with its last routine caller. # --------------------------------------------------------------------------- PICK_VERTEX_SHADER = """ -#version 330 core layout(location = 0) in vec3 in_position; layout(location = 2) in float in_lineno; @@ -830,11 +1270,11 @@ class ProgramArrayPickProgram(TrajectoryPickProgram): """ PICK_FRAGMENT_SHADER = """ -#version 330 core flat in int v_id; -out vec4 frag_color; - +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 @@ -844,8 +1284,11 @@ class ProgramArrayPickProgram(TrajectoryPickProgram): 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: @@ -876,7 +1319,6 @@ def delete(self) -> None: ) CONE_VERTEX_SHADER = """ -#version 330 core layout(location = 0) in vec3 in_position; layout(location = 1) in vec3 in_normal; @@ -892,7 +1334,6 @@ def delete(self) -> None: """ CONE_FRAGMENT_SHADER = """ -#version 330 core in vec3 v_normal; uniform vec3 u_light_dir; // direction toward the light (normalised) @@ -967,16 +1408,31 @@ def delete(self) -> None: class PickTarget: - """Offscreen RGBA8 + depth framebuffer used by the ID-buffer pick pass. + """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 both attachments because the pass only + 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 @@ -988,15 +1444,20 @@ def ensure(self, w: int, h: int) -> None: 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) @@ -1024,6 +1485,10 @@ def offscreen(self) -> Iterator[PickTarget]: 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) @@ -1048,14 +1513,26 @@ def resolve(self, x: int, y: int) -> int | None: 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) - depth = glReadPixels(rx, ry, 5, 5, GL_DEPTH_COMPONENT, GL_FLOAT) + 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. @@ -1064,7 +1541,7 @@ def decode(color: Any, depth: Any) -> int | None: integration. """ rgba = np.frombuffer(bytes(color), dtype=np.uint8).reshape(5, 5, 4) - d = np.frombuffer(bytes(depth), dtype=np.float32).reshape(5, 5) + 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 @@ -1073,10 +1550,15 @@ def decode(color: Any, depth: Any) -> int | None: | (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 - nearest = int(np.argmin(np.where(hit, d, np.inf))) + # 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: @@ -1084,6 +1566,8 @@ def delete(self) -> None: 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 @@ -1159,7 +1643,7 @@ def draw(self, renderer: GlCanonRenderer, mvp: Any, dash_period: float = 1.0, dash_duty: float = 0.5) -> None: """Draw the whole program: the trajectory, then the dwell markers. - The trajectory is one multi-draw whatever its mix of categories; the + 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 dashes and which the show-rapids toggle hides, so a buffer that @@ -1230,7 +1714,15 @@ class GlCanonRenderer: 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 @@ -1249,6 +1741,21 @@ def __init__(self) -> None: #: 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`. @@ -1264,12 +1771,48 @@ def register(self, resource: Any) -> Any: 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() @@ -1318,6 +1861,13 @@ def draw_line_array(self, mvp: Any, verts: WideVerts, 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: @@ -1325,6 +1875,14 @@ def draw_line_array(self, mvp: Any, verts: WideVerts, 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_dashed(dashed, dash_period) + wide.set_expansion(self._viewport, width) + self._scratch.draw_wide() + return line = self.line_program() line.begin(mvp, alpha) line.set_dashed(dashed, dash_period) @@ -1361,6 +1919,10 @@ def draw_cone(self, mvp: Any, normal_matrix: Any, 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[:] @@ -1372,6 +1934,10 @@ def delete(self) -> None: 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: @@ -1407,6 +1973,12 @@ def __init__(self, mode: GLEnum = GL_LINE_STRIP) -> None: 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 @@ -1465,6 +2037,11 @@ def upload(self, planes: Sequence[Any], attrs: Any, 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) @@ -1526,8 +2103,14 @@ def _plane_mvp(self, mvp: Any, plane: int) -> Any: out[:, 3] += out[:, 2] * dz return out - def _use(self, plane: int) -> None: - """Establish the recorded pass's shader state for one plane.""" + 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] @@ -1538,29 +2121,78 @@ def _use(self, plane: int) -> None: mvp, hide_cat=-1 if self._pass[3] else self.hide_cat, last_drawn_kind=self.last_drawn_kind) return - program = renderer.program_array_program() + program = (renderer.wide_program_array_program() if wide + else renderer.program_array_program()) if what == "override": program.begin(mvp, palette, dash_cat=-1, hide_cat=-1, last_drawn_kind=self.last_drawn_kind) program.set_override_color(self._pass[3]) - return - _w, _r, _m, alpha, show_rapids, dash_period, dash_duty = self._pass - program.begin(mvp, palette, alpha, - dash_cat=self.dash_cat, - hide_cat=-1 if show_rapids else self.hide_cat, - dashed=True, dash_period=dash_period, - dash_duty=dash_duty, - last_drawn_kind=self.last_drawn_kind) + else: + _w, _r, _m, alpha, show_rapids, dash_period, dash_duty = self._pass + program.begin(mvp, palette, alpha, + dash_cat=self.dash_cat, + hide_cat=-1 if show_rapids else self.hide_cat, + dashed=True, dash_period=dash_period, + dash_duty=dash_duty, + 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) - vao.bind() - glDrawArrays(self.mode, 0, self.count) - vao.unbind() + 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. @@ -1573,7 +2205,13 @@ def _resolve_spans(self) -> Any: return self.spans def draw_line(self, lineno: int | None) -> None: - """Draw only the spans belonging to source line ``lineno``.""" + """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() @@ -1584,19 +2222,36 @@ def draw_line(self, lineno: int | None) -> None: 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) - vao.bind() - for i in range(lo, hi): - glDrawArrays(self.mode, int(firsts[i]), int(counts[i])) - vao.unbind() + 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 @@ -1606,9 +2261,9 @@ class TrajectoryBuffers: """A buffer in the shared 20-byte vertex format, drawn through the trajectory shader. - Holds the vertex buffer, an optional chain table that - ``glMultiDrawArrays`` walks, the per-line spans the highlight pass draws, - and the colour palette the shader indexes with each vertex's category. + 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 @@ -1702,13 +2357,23 @@ def draw(self) -> None: 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): - glMultiDrawArrays(self.mode, self.firsts, self.counts, - 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() @@ -1753,6 +2418,10 @@ 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 @@ -1768,6 +2437,35 @@ def upload(self, verts: WideVerts, 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, dash_period: float = 1.0, dash_duty: float = 0.5) -> None: @@ -1814,6 +2512,9 @@ def draw_line(self, lineno: int | None) -> None: 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() @@ -2040,7 +2741,6 @@ def delete(self) -> None: _OVERLAY_ATTRS = ((0, 2, 0), (1, 2, 2 * 4)) TEXT_VERTEX_SHADER = """ -#version 330 core 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 @@ -2054,7 +2754,6 @@ def delete(self) -> None: """ TEXT_FRAGMENT_SHADER = """ -#version 330 core in vec2 v_uv; uniform sampler2D u_atlas; uniform vec4 u_color; diff --git a/lib/python/rs274/glcanon_scene.py b/lib/python/rs274/glcanon_scene.py index fe9cbba3bff..6cc6e30a535 100644 --- a/lib/python/rs274/glcanon_scene.py +++ b/lib/python/rs274/glcanon_scene.py @@ -176,7 +176,7 @@ class FrameContext: __slots__ = ( # drawing services - 'mv', 'prim', 'renderer', 'colors', + 'mv', 'prim', 'renderer', 'caps', 'colors', # machine and program state 'stat', 'canon', 'lp', 'geometry', 'is_lathe', 'is_foam', 'foam_z', 'foam_w', 'limits', 'joints_mode', @@ -201,6 +201,14 @@ class FrameContext: mv: MatrixStack prim: Primitives renderer: glcanon_gl.GlCanonRenderer + #: What the live GL context can do, where OpenGL 3.3 core and OpenGL ES 3.1 + #: differ - the API, and the widest line the driver grants. Read once + #: from the context at the top of the frame and passed + #: down: a part that wants to know must read this, and MUST NOT call + #: ``glGetString``, query an extension, or probe a limit itself. That is + #: the same rule as the visibility gate - a part does not decide, and does + #: not discover, the conditions it draws under. + caps: glcanon_gl.GLCaps #: Colour table, ``rs274.glcanon.GlCanonDraw.colors`` resolved for this #: host. Values are either an rgb 3-tuple of floats in 0..1 or, for the #: ``*_alpha`` keys, a bare float - hence ``Any`` rather than a union that @@ -923,12 +931,31 @@ class HighlightPart(Part): def __init__(self, resource: ProgramResource | None = None) -> None: self.resource = resource if resource is not None else ProgramResource() + #: Width over a program the driver drew at the same width. The tie-break + #: below plus three pixels against one is what makes the highlight read. + WIDTH = 3.0 + #: Width over a program the driver drew as hairlines. Wider because there + #: is less to win against - a 1px highlight over a 1px program differs + #: only in colour, and at a shallow angle on a busy program that is not + #: enough to find the selected line by eye. + HAIRLINE_WIDTH = 4.0 + @contextmanager def scope(self, ctx: FrameContext) -> Iterator[None]: """The program's alpha compositing, plus the depth tie-break and the - width that make the highlight sit over the geometry it duplicates.""" + width that make the highlight sit over the geometry it duplicates. + + The width is the one place in the scene where the driver's own limit + changes what a part asks for. Where ``glLineWidth`` is capped at 1 - + a forward-compatible core profile, or GLES on the Raspberry Pi's v3d - + the program body is drawn as hairlines and is not quad-expanded (10M + vertices is too many), so the highlight asks for more and gets it: it + *is* quad-expanded, being a handful of segments. Read from the frame's + capability record; a part never asks GL this itself. + """ with super().scope(ctx), program_alpha(ctx): - set_line_width(3.0) + set_line_width(self.WIDTH if ctx.caps.max_line_width >= self.WIDTH + else self.HAIRLINE_WIDTH) glDepthFunc(GL_LEQUAL) try: yield diff --git a/src/emc/usr_intf/axis/extensions/togl.c b/src/emc/usr_intf/axis/extensions/togl.c index 23e54b61bef..124755abdd7 100644 --- a/src/emc/usr_intf/axis/extensions/togl.c +++ b/src/emc/usr_intf/axis/extensions/togl.c @@ -1627,6 +1627,21 @@ static LRESULT CALLBACK Win32WinProc( HWND hwnd, UINT message, #ifndef GLX_CONTEXT_CORE_PROFILE_BIT_ARB #define GLX_CONTEXT_CORE_PROFILE_BIT_ARB 0x00000001 #endif +/* GLX_EXT_create_context_es2_profile. Mesa exposes it wherever it exposes + * GLES, which includes the Raspberry Pi's v3d - a driver with no desktop core + * profile at all, and the reason the second request below exists. */ +#ifndef GLX_CONTEXT_ES_PROFILE_BIT_EXT +#define GLX_CONTEXT_ES_PROFILE_BIT_EXT 0x00000004 +#endif + +/* A refused context request raises BadMatch/BadValue on the X connection, and + * Xlib's default handler exits the process. Asking for 3.3 core on a driver + * that has none is an expected step now, not a fatal one, so it is made with + * this installed and the null return value is the answer. */ +static int Togl_IgnoreXError(Display *dpy, XErrorEvent *event) { + (void)dpy; (void)event; + return 0; +} /* @@ -1692,10 +1707,16 @@ static Window Togl_CreateWindow(Tk_Window tkwin, printf("SHARE CTX\n"); } else if (togl->CoreProfileFlag) { - /* OpenGL 3.3 core-profile context via an FBConfig and - * glXCreateContextAttribsARB. Used by AXIS's modern preview renderer; - * the default (below) is left untouched so vismach and other Togl users - * keep their legacy contexts. */ + /* The preview renderer's context, via an FBConfig and + * glXCreateContextAttribsARB: OpenGL 3.3 core where the driver has it, + * OpenGL ES 3.1 where it does not (Mesa's v3d on a Raspberry Pi 4 has no + * desktop core profile at all - maximum core version 0.0, compatibility + * profile 2.1). The renderer is the same either way; only the API + * differs, and rs274.glcanon_gl reads which one off the context. + * + * Used by AXIS's modern preview renderer; the default (below) is left + * untouched so vismach and other Togl users keep their legacy + * contexts. */ int fb_attribs[] = { GLX_X_RENDERABLE, True, GLX_DRAWABLE_TYPE, GLX_WINDOW_BIT, @@ -1712,18 +1733,26 @@ static Window Togl_CreateWindow(Tk_Window tkwin, GLXFBConfig fbconfig; GLXContext (*createContextAttribs)(Display*, GLXFBConfig, GLXContext, Bool, const int*); - int ctx_attribs[] = { + int core_attribs[] = { GLX_CONTEXT_MAJOR_VERSION_ARB, 3, GLX_CONTEXT_MINOR_VERSION_ARB, 3, GLX_CONTEXT_PROFILE_MASK_ARB, GLX_CONTEXT_CORE_PROFILE_BIT_ARB, None }; + int es_attribs[] = { + GLX_CONTEXT_MAJOR_VERSION_ARB, 3, + GLX_CONTEXT_MINOR_VERSION_ARB, 1, + GLX_CONTEXT_PROFILE_MASK_ARB, GLX_CONTEXT_ES_PROFILE_BIT_EXT, + None + }; + int (*prevHandler)(Display*, XErrorEvent*); fbconfigs = glXChooseFBConfig(dpy, Tk_ScreenNumber(togl->TkWin), fb_attribs, &nconfigs); if (!fbconfigs || nconfigs < 1) { Tcl_SetResult(togl->Interp, "Togl: no framebuffer config for OpenGL " - "3.3 core; requires Mesa (try LIBGL_ALWAYS_SOFTWARE=1)", TCL_STATIC); + "3.3 core or OpenGL ES 3.1; requires Mesa (try " + "LIBGL_ALWAYS_SOFTWARE=1)", TCL_STATIC); return DUMMY_WINDOW; } fbconfig = fbconfigs[0]; @@ -1731,8 +1760,8 @@ static Window Togl_CreateWindow(Tk_Window tkwin, XFree(fbconfigs); if (!visinfo) { Tcl_SetResult(togl->Interp, - "Togl: glXGetVisualFromFBConfig failed for OpenGL 3.3 core", - TCL_STATIC); + "Togl: glXGetVisualFromFBConfig failed for the preview " + "renderer's framebuffer config", TCL_STATIC); return DUMMY_WINDOW; } @@ -1741,17 +1770,31 @@ static Window Togl_CreateWindow(Tk_Window tkwin, glXGetProcAddressARB((const GLubyte*)"glXCreateContextAttribsARB"); if (!createContextAttribs) { Tcl_SetResult(togl->Interp, "Togl: glXCreateContextAttribsARB " - "unavailable; OpenGL 3.3 core required (try LIBGL_ALWAYS_SOFTWARE=1)", - TCL_STATIC); + "unavailable; OpenGL 3.3 core or OpenGL ES 3.1 required (try " + "LIBGL_ALWAYS_SOFTWARE=1)", TCL_STATIC); return DUMMY_WINDOW; } if (togl->Indirect) directCtx = GL_FALSE; + + /* Desktop 3.3 core first, so a machine that has always taken that path + * keeps taking it. Both attempts run with the error handler swapped out: + * a refusal is how the two are told apart, not a reason to exit. */ + prevHandler = XSetErrorHandler(Togl_IgnoreXError); togl->GlCtx = createContextAttribs(dpy, fbconfig, NULL, directCtx, - ctx_attribs); + core_attribs); + XSync(dpy, False); + if (togl->GlCtx == NULL) { + togl->GlCtx = createContextAttribs(dpy, fbconfig, NULL, directCtx, + es_attribs); + XSync(dpy, False); + } + XSetErrorHandler(prevHandler); + if (togl->GlCtx == NULL) { Tcl_SetResult(togl->Interp, "Togl: could not create an OpenGL 3.3 " - "core context (try LIBGL_ALWAYS_SOFTWARE=1)", TCL_STATIC); + "core or OpenGL ES 3.1 context; the preview renderer needs one of " + "them (try LIBGL_ALWAYS_SOFTWARE=1)", TCL_STATIC); return DUMMY_WINDOW; } } diff --git a/src/emc/usr_intf/gremlin/gremlin.py b/src/emc/usr_intf/gremlin/gremlin.py index 09d321bac6e..f4a5d1a4595 100755 --- a/src/emc/usr_intf/gremlin/gremlin.py +++ b/src/emc/usr_intf/gremlin/gremlin.py @@ -64,11 +64,23 @@ _glx.glXMakeCurrent.argtypes = [c_void_p, c_ulong, c_void_p] _glx.glXSwapBuffers.restype = None _glx.glXSwapBuffers.argtypes = [c_void_p, c_ulong] +_glx.XSetErrorHandler.restype = c_void_p +_glx.XSetErrorHandler.argtypes = [c_void_p] +_glx.XSync.restype = c_int +_glx.XSync.argtypes = [c_void_p, c_int] _ccaa_proc = _glx.glXGetProcAddress(b"glXCreateContextAttribsARB") _glXCreateContextAttribsARB = CFUNCTYPE( c_void_p, c_void_p, c_void_p, c_void_p, c_int, POINTER(c_int))( _ccaa_proc) if _ccaa_proc else None +# A refused context request raises BadMatch/BadValue on the X connection, and +# Xlib's default handler exits the process. Asking for 3.3 core on a driver +# that has none is now an expected step rather than a fatal one, so the +# attempt below installs this for its duration: swallow, and let the null +# return value be the answer. +_XErrorHandler = CFUNCTYPE(c_int, c_void_p, c_void_p) +_IGNORE_X_ERROR = _XErrorHandler(lambda display, event: 0) + # FBConfig / GLX_ARB_create_context(_profile) attribute tokens (stable GLX ints). GLX_X_RENDERABLE = 0x8012 GLX_DRAWABLE_TYPE = 0x8010 @@ -85,6 +97,10 @@ GLX_CONTEXT_MINOR_VERSION_ARB = 0x2092 GLX_CONTEXT_PROFILE_MASK_ARB = 0x9126 GLX_CONTEXT_CORE_PROFILE_BIT_ARB = 0x00000001 +# GLX_EXT_create_context_es2_profile. Mesa exposes it wherever it exposes +# GLES, which includes the Raspberry Pi's v3d - the driver that has no desktop +# core profile at all and is the reason this second request exists. +GLX_CONTEXT_ES_PROFILE_BIT_EXT = 0x00000004 try: import Xlib @@ -245,13 +261,22 @@ def C(s): def _create_core_context(self): - """Create an OpenGL 3.3 core-profile context via GLX. + """Create the preview's GL context: 3.3 core, else GLES 3.1. GTK3 only hands out core contexts through GtkGLArea, so gremlin builds one by hand (as it always has for the legacy context): pick an FBConfig and call glXCreateContextAttribsARB for 3.3 core. The X window binding - (activate/swapbuffers) is unchanged. Hard failure with a diagnostic if a - core context cannot be made - the preview renderer requires 3.3 core. + (activate/swapbuffers) is unchanged. + + A driver with no desktop core profile at all - Mesa's ``v3d`` on the + Raspberry Pi 4, whose maximum core version is 0.0 and whose + compatibility profile stops at 2.1 - fails that request. Its real API + is OpenGL ES 3.1, so that is tried next, over the same GLX drawable + through ``GLX_EXT_create_context_es2_profile``. The renderer is the + same renderer either way; only the API differs, which is what + ``rs274.glcanon_gl.GLCaps`` reads off the context afterwards. + + Hard failure with a diagnostic naming both if neither can be made. """ if not _glXCreateContextAttribsARB: self._core_context_failed( @@ -277,23 +302,55 @@ def _create_core_context(self): if not fbconfigs or n.value < 1: self._core_context_failed("no suitable framebuffer configuration") - ctx_attribs = [ + # Desktop 3.3 core first: where both are available it is what runs, so + # a machine that has always taken this path keeps taking it. + self.context = self._try_context(dpy, fbconfigs[0], [ GLX_CONTEXT_MAJOR_VERSION_ARB, 3, GLX_CONTEXT_MINOR_VERSION_ARB, 3, GLX_CONTEXT_PROFILE_MASK_ARB, GLX_CONTEXT_CORE_PROFILE_BIT_ARB, 0, - ] - self.context = _glXCreateContextAttribsARB( - dpy, fbconfigs[0], None, True, - (c_int * len(ctx_attribs))(*ctx_attribs)) - if not self.context: - self._core_context_failed( - "glXCreateContextAttribsARB returned no context") + ]) + if self.context: + self.gl_api = "OpenGL 3.3 core" + return + self.context = self._try_context(dpy, fbconfigs[0], [ + GLX_CONTEXT_MAJOR_VERSION_ARB, 3, + GLX_CONTEXT_MINOR_VERSION_ARB, 1, + GLX_CONTEXT_PROFILE_MASK_ARB, GLX_CONTEXT_ES_PROFILE_BIT_EXT, + 0, + ]) + if self.context: + self.gl_api = "OpenGL ES 3.1" + return + self._core_context_failed( + "neither request returned a context") + + @staticmethod + def _try_context(dpy, fbconfig, ctx_attribs): + """One glXCreateContextAttribsARB attempt, or None. + + A refused request is normal here - it is how the desktop and GLES + paths are told apart - so the X error it raises must not reach the + default handler, which would exit the process. The handler is swapped + for the duration of the call and put back afterwards. + """ + previous = _glx.XSetErrorHandler(_IGNORE_X_ERROR) + try: + context = _glXCreateContextAttribsARB( + dpy, fbconfig, None, True, + (c_int * len(ctx_attribs))(*ctx_attribs)) + except Exception: + context = None + finally: + _glx.XSync(dpy, False) + _glx.XSetErrorHandler(previous) + return context or None def _core_context_failed(self, why): sys.stderr.write( - "\nGremlin: could not create an OpenGL 3.3 core context: %s\n" - "The preview renderer requires OpenGL 3.3 core (Mesa). On a machine\n" + "\nGremlin: could not create a usable OpenGL context: %s\n" + "The preview renderer needs either OpenGL 3.3 core or OpenGL ES\n" + "3.1; both were requested and both were refused. On a machine\n" "without a capable GPU, force software rendering with:\n" " LIBGL_ALWAYS_SOFTWARE=1\n\n" % why) raise SystemExit(1) diff --git a/src/emc/usr_intf/gremlin/qt5_graphics.py b/src/emc/usr_intf/gremlin/qt5_graphics.py index 86dfde25f04..90161418bdb 100644 --- a/src/emc/usr_intf/gremlin/qt5_graphics.py +++ b/src/emc/usr_intf/gremlin/qt5_graphics.py @@ -187,15 +187,58 @@ class Lcnc_3dGraphics(QOpenGLWidget, glcanon.GlCanonDraw, glnav.GlNavBase): zRotationChanged = Signal(int) rotation_vectors = [(1.,0.,0.), (0., 0., 1.)] + @staticmethod + def _desktop_core_available(): + """Whether this machine can give the widget an OpenGL 3.3 core context. + + Asked by creating a throwaway context and reading back the format that + came out - Qt has no way to answer it from the format alone, and a + create() that "succeeds" at 2.1 would leave the widget with a context + the renderer cannot use. + + Anything unexpected answers True, which keeps the pre-change behaviour + (request 3.3 core, fail loudly if it is not there) rather than quietly + dropping a capable machine onto the GLES path. + """ + try: + from qtpy.QtGui import QOpenGLContext + if QOpenGLContext.openGLModuleType() == QOpenGLContext.LibGLES: + # Qt itself is linked against GLES; no desktop request can be + # satisfied whatever the driver holds. + return False + probe = QSurfaceFormat() + probe.setVersion(3, 3) + probe.setProfile(QSurfaceFormat.CoreProfile) + ctx = QOpenGLContext() + ctx.setFormat(probe) + if not ctx.create(): + return False + got = ctx.format() + return (got.profile() == QSurfaceFormat.CoreProfile + and (got.majorVersion(), got.minorVersion()) >= (3, 3)) + except Exception: + return True + def __init__(self, parent=None): super(Lcnc_3dGraphics,self).__init__(parent) - # Request an OpenGL 3.3 core-profile surface before the widget's context - # is created; the shared preview renderer (rs274.glcanon_gl) requires - # 3.3 core. setFormat must precede the widget's first show. + # Request the surface the shared preview renderer (rs274.glcanon_gl) + # can draw on, before the widget's context is created; setFormat must + # precede the widget's first show. + # + # OpenGL 3.3 core where it exists. Where it does not - Mesa's v3d on a + # Raspberry Pi 4 has no desktop core profile at all - the same renderer + # runs on OpenGL ES 3.1, so that is asked for instead. Qt cannot report + # whether a format is obtainable before the context exists, so the + # choice is made from what the default (throwaway) context says the + # driver supports. fmt = QSurfaceFormat() - fmt.setVersion(3, 3) - fmt.setProfile(QSurfaceFormat.CoreProfile) fmt.setDepthBufferSize(24) + if self._desktop_core_available(): + fmt.setVersion(3, 3) + fmt.setProfile(QSurfaceFormat.CoreProfile) + else: + fmt.setRenderableType(QSurfaceFormat.OpenGLES) + fmt.setVersion(3, 1) self.setFormat(fmt) glnav.GlNavBase.__init__(self) From 0f3f8308cab9539aae797a346ca407f90ff00d40 Mon Sep 17 00:00:00 2001 From: Alexey Presnyakov <309782758+alex-pres@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:18:13 +0400 Subject: [PATCH 6/8] glcanon: restore parity with master preview, then make the fill cheaper Compared against a stock-master build rather than against the previous state of this rewrite, the program was compositing alpha whether or not the toggle asked for it - feed pixels landing at (85,85,85) instead of white on 76,763 px of fractal-1M. Blending is gated on the toggle again. With rapids solid the per-vertex dash distance became unreachable, so it and its uniforms go: VERTEX_STRIDE 24 -> 20 bytes, 40 -> 32 in foam. The fill then stopped asking numpy for reductions in the shape it is worst at - the box of 16384 points costs 258 us whole-array against 22 us column by column - for about 4x, producing identical output. --- lib/python/rs274/glcanon.py | 3 - lib/python/rs274/glcanon_bake.py | 330 +++++++++++++++++++----------- lib/python/rs274/glcanon_gl.py | 270 +++++++++--------------- lib/python/rs274/glcanon_scene.py | 119 ++++++++--- 4 files changed, 385 insertions(+), 337 deletions(-) diff --git a/lib/python/rs274/glcanon.py b/lib/python/rs274/glcanon.py index bc9128dd623..7537ecf3026 100644 --- a/lib/python/rs274/glcanon.py +++ b/lib/python/rs274/glcanon.py @@ -917,9 +917,6 @@ def _cone_mesh_verts(self): def _draw_cone_core(self, color, mesh_verts=None): self.prim.draw_cone(self, color, mesh_verts) - def _dash_period(self): - return glcanon_scene.ProgramPart.dash_period(self.frame_context()) - def select(self, x_view, y_view): """Select the program line under the cursor. Thin delegate to the Picker; the ID-buffer pick itself lives there.""" diff --git a/lib/python/rs274/glcanon_bake.py b/lib/python/rs274/glcanon_bake.py index 203dbe100ba..5b95c484faf 100644 --- a/lib/python/rs274/glcanon_bake.py +++ b/lib/python/rs274/glcanon_bake.py @@ -8,7 +8,7 @@ # parameterised by a radius and a height rather than anything the canon # records, and the live backplot, which is the path the machine has actually # travelled, streamed out of the C position logger's ring buffer while a -# program runs. The backplot shares the program's *shader* and its 20-byte +# program runs. The backplot shares the program's *shader* and its 16-byte # palette-indexed vertex; the solids' interleaved position+normal layout is # its own and is named as such. # @@ -63,7 +63,7 @@ # float64 -> float32 boundary in this module speaks this type. Float64Points = npt.NDArray[np.float64] -# Interleaved layout: position(3) rgba(4) lineno(1) distance(1) -> 9 float32. +# Interleaved layout: position(3) rgba(4) lineno(1) -> 8 float32. # ``WideVerts`` is the type saying so; rs274.glcanon_gl names the same layout # through this alias, which is what keeps the two modules' strides one # statement rather than two comments asking each other to agree. It is what @@ -71,17 +71,17 @@ # uses, since that is rebuilt every frame from view-dependent colours that # don't reduce to a small palette, and what the live backplot falls back to # when its colours overflow its palette. -FLOATS_PER_VERTEX = 9 +FLOATS_PER_VERTEX = 8 WideVerts = npt.NDArray[np.float32] # (N, FLOATS_PER_VERTEX) -# The live backplot's layout: position(3 float32), a uint32 holding a palette -# index in its high byte, and an unused distance (float32) -> 20 bytes. Colour -# is not stored per vertex; the shader looks it up in the palette. The packed -# word is carried in a float32 column purely as a bit container - it is never -# read as a number, and the values involved (an index <= 7) can never form a -# NaN pattern that a copy might quiet. See :func:`backplot_vertices`, which is -# the only thing that writes it; the program has its own layout, below. -TRAJ_FLOATS_PER_VERTEX = 5 +# The live backplot's layout: position(3 float32) and a uint32 holding a +# palette index in its high byte -> 16 bytes. Colour is not stored per vertex; +# the shader looks it up in the palette. The packed word is carried in a +# float32 column purely as a bit container - it is never read as a number, and +# the values involved (an index <= 7) can never form a NaN pattern that a copy +# might quiet. See :func:`backplot_vertices`, which is the only thing that +# writes it; the program has its own layout, below. +TRAJ_FLOATS_PER_VERTEX = 4 TrajectoryVerts = npt.NDArray[np.float32] # (N, TRAJ_FLOATS_PER_VERTEX) # The Lambert-shaded solids' layout: position(3) + normal(3) float32, (N, 6), @@ -130,21 +130,20 @@ # --------------------------------------------------------------------------- # The program array's vertex layout, stated once here and read by -# rs274.glcanon_gl rather than restated there - as the 20-byte layout above is. +# rs274.glcanon_gl rather than restated there - as the 16-byte layout above is. # -# 24 bytes per vertex, in two arrays rather than one interleaved buffer: +# 20 bytes per vertex, in two arrays rather than one interleaved buffer: # -# per plane position 3 x float32 + distance float32 = 16 B +# per plane position 3 x float32 = 12 B # shared source line uint32 + kind/tool uint32 = 8 B # # The split is what lets foam - which draws the same program on two planes, # ``XY`` at ``foam_z`` and ``UV`` at ``foam_w`` - store the line, kind and tool -# columns once for both. Distance sits with the position, not with them, -# because the dash phase accumulates along *transformed* points and the two -# planes' points differ; sharing it would silently change one plane's dashes. -PLANE_DTYPE = np.dtype([('pos', ' tuple[Float64Points, Float64Points]: + """``(min, max)`` over the first three columns, taken one column at a time. + + numpy's ``axis=0`` reduction vectorises the three-wide row, not the k-long + column: 258 us against 22 us at k=16384. Same answer, either way - which + is why this exists as one named helper rather than four open-coded loops. + Several arrays reduce into one box without concatenating them, since the + copy costs more than the reduction it saves. + + Every caller has at least one row; an empty batch never reaches the fill. + """ + lo = np.empty(3, dtype=np.float64) + hi = np.empty(3, dtype=np.float64) + for j in range(3): + col = point_arrays[0][:, j] + col_lo = col.min() + col_hi = col.max() + for arr in point_arrays[1:]: + col = arr[:, j] + col_lo = min(col_lo, col.min()) + col_hi = max(col_hi, col.max()) + lo[j] = col_lo + hi[j] = col_hi + return lo, hi + + +def _seg_lengths(p1: Float64Points, p2: Float64Points) -> Float64Points: + """Each move's XYZ segment length, over the three columns it reads. + + Bit-identical to ``np.linalg.norm((p2 - p1)[:, :3], axis=1)``, which + subtracted all nine columns to use three and then took a row-wise + reduction over a three-wide array. ``norm`` reduces the three squares in + this same order, so the sum - and therefore the root - is the same float. + """ + dx = p2[:, 0] - p1[:, 0] + dy = p2[:, 1] - p1[:, 1] + dz = p2[:, 2] - p1[:, 2] + return np.sqrt(dx * dx + dy * dy + dz * dz) + + +#: Runs of one commanded feed rate that :meth:`ProgramGeometry. +#: _accumulate_lengths` will find by walking rate changes rather than by +#: sorting the batch. Past it the run form's ``unique`` + ``repeat`` costs more +#: than the sort it replaces, so the sort runs instead. A **cost** switch, not +#: a correctness one: both branches feed the same ``bincount`` the same bins +#: and produce the same table bit for bit, so a wrong value here costs time and +#: nothing else. +_RATE_RUN_LIMIT = 64 + + class ProgramGeometry: """The parsed program, as arrays. The authoritative record of what it is. It is the program record and the ready-to-go GPU's source data at once. @@ -316,8 +366,8 @@ class ProgramGeometry: and nothing here knows that OpenGL exists. **Storage.** Two arrays, per the layout stated at the top of this module: - one :data:`PLANE_DTYPE` array per drawn plane (position and dash distance, - which are both plane-specific because the transform is) and one shared + one :data:`PLANE_DTYPE` array per drawn plane (the transformed position, + which is plane-specific because the transform is) and one shared :data:`ATTR_DTYPE` array (source line, and the packed kind/tool word). Both grow by doubling; the move count is not known in advance and a counting pass would mean holding or re-walking the source. @@ -385,10 +435,6 @@ def clear(self) -> None: self._n = 0 self._planes = [np.empty(0, dtype=PLANE_DTYPE) for _ in self.planes] self._attrs = np.empty(0, dtype=ATTR_DTYPE) - # Per plane: the last vertex's position and its dash distance, so a - # chunk continues the previous chunk's segment and dash phase. - self._prev_pos = [np.zeros(3) for _ in self.planes] - self._dash = [0.0 for _ in self.planes] #: The 9-DOF point the trajectory is currently at, or ``None`` before #: the first move. A move starting anywhere else is a jump. self._cur9: Optional[npt.NDArray[np.float64]] = None @@ -462,9 +508,6 @@ def attrs(self) -> npt.NDArray[Any]: def positions(self, plane: int = 0) -> npt.NDArray[np.float32]: return self._planes[plane]['pos'][:self._n] - def distance(self, plane: int = 0) -> npt.NDArray[np.float32]: - return self._planes[plane]['dist'][:self._n] - @property def lines(self) -> npt.NDArray[np.uint32]: return self._attrs['line'][:self._n] @@ -520,8 +563,8 @@ def drawn_extents(self) -> npt.NDArray[np.float64]: A different quantity from :attr:`extents`, and named apart because it coincides with it only when the GEOMETRY transform is the identity. It - is the box a dash period or a view fit wants; the four pairs above are - the machine-frame boxes the properties dialog and the DRO show. + is the box a view fit wants; the four pairs above are the machine-frame + boxes the properties dialog and the DRO show. The foam planes' Z offsets are not included, for the same reason they are not baked into the positions. @@ -634,37 +677,52 @@ def _fill_arrays(self, lines: npt.NDArray[np.int64], p1: Float64Points, # 2. How many points each move contributes, and whether it needs a # record vertex at its start because the trajectory jumped to get # there. - steps = _rotary_steps_batch(p1, p2) - prev = np.empty_like(p1) - prev[1:] = p2[:-1] - jump = np.empty(k, dtype=bool) - jump[1:] = np.any(p1[1:] != prev[1:], axis=1) + turning = _any_rotary_change(p1, p2) + steps = _rotary_steps_batch(p1, p2) if turning else None # Nothing has been drawn yet: the first move's start point is a jump # by definition, which is also what starts the strip. - jump[0] = (True if self._cur9 is None - else bool(np.any(p1[0] != self._cur9))) - counts = steps + jump - total = int(counts.sum()) + first_jump = (True if self._cur9 is None + else bool(np.any(p1[0] != self._cur9))) + # p2[:-1] is the previous move's end point, so this is the whole + # continuity test; the per-move flags are built only when it fails. + continuous = not first_jump and bool(np.array_equal(p1[1:], p2[:-1])) # 3. Expand. ``t == 0`` reproduces p1 exactly, which is what makes the # jump record fall out of the same expression as the interpolation # rather than needing a branch. - ends = np.cumsum(counts) - move_idx = np.repeat(np.arange(k), counts) - within = np.arange(total) - (ends - counts)[move_idx] - sub = within - jump[move_idx] + 1 - if steps.max() == 1 and not jump.any(): - pts9 = p2 # the common case: no copy at all - else: - t = (sub / steps[move_idx])[:, np.newaxis] - pts9 = t * p2[move_idx] + (1.0 - t) * p1[move_idx] - + # # 4. Columns. The record vertex at a jump carries the kind that says # "discard the segment into me" and the line number of the move it # starts, which is what the pre-change chain head carried. - line_col = lines[move_idx].astype(np.uint32) - kind_col = kind_in[move_idx].copy() - kind_col[sub == 0] = KIND_NOOP + if not turning and continuous: + # Nothing turning, nothing jumping: the vertices are the moves' end + # points, and every index array below would be the identity. + pts9 = p2 + line_col = lines.astype(np.uint32) + kind_col = kind_in + else: + if continuous: + jump = np.zeros(k, dtype=bool) + else: + jump = np.empty(k, dtype=bool) + jump[1:] = np.any(p1[1:] != p2[:-1], axis=1) + jump[0] = first_jump + if steps is None: + steps = np.ones(k, dtype=np.int64) + counts = steps + jump + total = int(counts.sum()) + ends = np.cumsum(counts) + move_idx = np.repeat(np.arange(k), counts) + within = np.arange(total) - (ends - counts)[move_idx] + sub = within - jump[move_idx] + 1 + if steps.max() == 1 and not jump.any(): + pts9 = p2 # the common case: no copy at all + else: + t = (sub / steps[move_idx])[:, np.newaxis] + pts9 = t * p2[move_idx] + (1.0 - t) * p1[move_idx] + line_col = lines[move_idx].astype(np.uint32) + kind_col = kind_in[move_idx].copy() + kind_col[sub == 0] = KIND_NOOP # 5. One transform per plane per batch, never one per move. points = [transform_points(pts9, geom, self.ro) @@ -749,20 +807,18 @@ def _write(self, points: Sequence[Float64Points], n = self._n for i, arr in enumerate(self._planes): pos = points[i] - dist = self._distances(i, pos, kind_col) arr['pos'][n:n + m] = pos - arr['dist'][n:n + m] = dist - # Both carried at float64: the stored column is float32, and - # rounding a running total once per batch would make the dash - # phase depend on where the batches happened to fall. - self._prev_pos[i] = pos[-1] - self._dash[i] = float(dist[-1]) - np.minimum(self._drawn[0], pos.min(axis=0), out=self._drawn[0]) - np.maximum(self._drawn[1], pos.max(axis=0), out=self._drawn[1]) + lo, hi = _box_of(pos) + np.minimum(self._drawn[0], lo, out=self._drawn[0]) + np.maximum(self._drawn[1], hi, out=self._drawn[1]) self._attrs['line'][n:n + m] = line_col - self._attrs['kindtool'][n:n + m] = ( - kind_col.astype(np.uint32) - | (np.uint32(self._tool) << np.uint32(TOOL_SHIFT))) + # Into the array's own field, tool ordinal or-ed on there: no + # batch-sized temporaries. Must not mutate kind_col - the unsubdivided + # path hands over the canon's pending kinds themselves, which can be a + # read-only ``frombuffer`` view. + field = self._attrs['kindtool'][n:n + m] + np.copyto(field, kind_col) + field |= np.uint32(self._tool) << np.uint32(TOOL_SHIFT) self._n = n + m self._index = None @@ -783,35 +839,6 @@ def _reserve(self, extra: int) -> None: grown_attrs[:self._n] = self._attrs[:self._n] self._attrs = grown_attrs - def _distances(self, plane: int, pos: Float64Points, - kind_col: npt.NDArray[np.uint8]) -> npt.NDArray[np.float64]: - """Dash distance for one batch of vertices on one plane. - - The same segmented accumulation ``_chain_distances`` performed per - chain, carried across batches: it climbs through a maximal run of - rapid segments and resets at the start of every such run, which keeps - the magnitude small enough for float32 to resolve the dash period - however long the program is. - - A record vertex is neither: a jump resets the phase, because it is - exactly where a chain used to restart; a dwell or a tool-change record - does not, because it adds no length and the rapid run it interrupts is - still one run. Getting that second case wrong would change the dashes - of any rapid that happens to straddle a ``G4``. - """ - m = len(pos) - step = np.empty((m, 3), dtype=np.float64) - step[0] = pos[0] - self._prev_pos[plane] - step[1:] = np.diff(pos, axis=0) - seglen = np.linalg.norm(step, axis=1) - running = np.cumsum(np.where(kind_col == KIND_TRAVERSE, seglen, 0.0)) - resets = ((kind_col == KIND_FEED) | (kind_col == KIND_ARC) - | (kind_col == KIND_NOOP)) - last = np.maximum.accumulate(np.where(resets, np.arange(m), -1)) - prior = np.where(last >= 0, running[np.maximum(last, 0)], - -self._dash[plane]) - return running - prior - # -- extents accumulation --------------------------------------------- def _accumulate_extents(self, p1: Float64Points, p2: Float64Points, @@ -826,15 +853,37 @@ def _accumulate_extents(self, p1: Float64Points, p2: Float64Points, region, say - is invisible to it. The two agree on every fixture in the corpus; where they could differ this is the larger box and the right one. + + Of the four pairs, only the first is always reduced. The other three + are derived from it where the batch permits, and reduced in full where + it does not - each derivation has its general form as the else branch + beside it, so a batch containing a tool change, or filled under a g5x + rotation, is answered exactly as it was before this shortcut existed. """ - pts = np.concatenate([p1[:, :3], p2[:, :3]]) - tool = np.concatenate([offsets, offsets]) - rot = _unrotate_xy(pts, rotation_xy, g5x_xy) - for i, values in enumerate((pts, pts + tool, rot, rot + tool)): - np.minimum(self._extents[i, 0], values.min(axis=0), - out=self._extents[i, 0]) - np.maximum(self._extents[i, 1], values.max(axis=0), - out=self._extents[i, 1]) + raw = _box_of(p1, p2) + one_offset = bool((offsets == offsets[0]).all()) + if one_offset: + # One offset for the whole batch - i.e. no tool change in it - so + # the corrected box is the raw box shifted. Adding a constant is + # monotonic, so this is the same box, not an approximation of it. + shift = offsets[0] + notool = (raw[0] + shift, raw[1] + shift) + else: + notool = _box_of(p1[:, :3] + offsets, p2[:, :3] + offsets) + if not rotation_xy: + # No rotation to remove: these two pairs are the two above. + rot, rot_notool = raw, notool + else: + r1 = _unrotate_xy(p1[:, :3], rotation_xy, g5x_xy) + r2 = _unrotate_xy(p2[:, :3], rotation_xy, g5x_xy) + rot = _box_of(r1, r2) + if one_offset: + rot_notool = (rot[0] + shift, rot[1] + shift) + else: + rot_notool = _box_of(r1 + offsets, r2 + offsets) + for i, (lo, hi) in enumerate((raw, notool, rot, rot_notool)): + np.minimum(self._extents[i, 0], lo, out=self._extents[i, 0]) + np.maximum(self._extents[i, 1], hi, out=self._extents[i, 1]) def _accumulate_lengths(self, p1: Float64Points, p2: Float64Points, kind_in: npt.NDArray[np.uint8], @@ -843,19 +892,38 @@ def _accumulate_lengths(self, p1: Float64Points, p2: Float64Points, Same raw XYZ endpoints as :meth:`_accumulate_extents`, so this must be called before subdivision too. The per-rate reduction is vectorised - (``np.unique`` + ``np.bincount``) rather than looped per move; only the - result - one Python dict update per *distinct rate in this batch* - is - a Python-level loop, which is what keeps the table bounded by feed-rate - changes rather than move count. + (``np.bincount`` over the distinct rates) rather than looped per move; + only the result - one Python dict update per *distinct rate in this + batch* - is a Python-level loop, which is what keeps the table bounded + by feed-rate changes rather than move count. """ - seglen = np.linalg.norm((p2 - p1)[:, :3], axis=1) + seglen = _seg_lengths(p1, p2) is_traverse = kind_in == CAT_TRAVERSE - self._rapid_length += float(seglen[is_traverse].sum()) - cut_rates = feedrates[~is_traverse] - if len(cut_rates) == 0: + n_traverse = int(is_traverse.sum()) + if n_traverse == len(kind_in): + # Nothing cutting: no selection copy, and no table to update. + self._rapid_length += float(seglen.sum()) return - cut_lengths = seglen[~is_traverse] - uniq, inverse = np.unique(cut_rates, return_inverse=True) + if n_traverse == 0: + # Nothing traversing: the batch *is* its own cutting selection, so + # neither boolean index copy is made. + cut_rates, cut_lengths = feedrates, seglen + else: + self._rapid_length += float(seglen[is_traverse].sum()) + cutting = ~is_traverse + cut_rates = feedrates[cutting] + cut_lengths = seglen[cutting] + # A commanded rate holds for a run of moves, so sort the runs, not the + # batch. Same bincount over the same bins, so the table is unchanged + # bit for bit. Past the limit the run form costs more than the sort. + starts = np.concatenate( + ([0], np.flatnonzero(cut_rates[1:] != cut_rates[:-1]) + 1)) + if len(starts) <= _RATE_RUN_LIMIT: + uniq, run_bins = np.unique(cut_rates[starts], return_inverse=True) + inverse = np.repeat( + run_bins, np.diff(np.append(starts, len(cut_rates)))) + else: + uniq, inverse = np.unique(cut_rates, return_inverse=True) sums = np.bincount(inverse, weights=cut_lengths, minlength=len(uniq)) for rate, length in zip(uniq.tolist(), sums.tolist()): self._cut_length_by_feed[rate] = ( @@ -1072,9 +1140,9 @@ def dwell_marker_part(geometry: "ProgramGeometry", is_lathe: bool = False, "palettes": [padded] * n_planes, "plane_offsets": tuple(offsets[:n_planes]), "mode": MODE_LINES, - # No record kinds here, and no rapid to hide or dash: every entry - # is a colour, so the whole palette is drawable. - "dash_cat": -1, "hide_cat": -1, + # No record kinds here, and no rapid to hide: every entry is a + # colour, so the whole palette is drawable. + "hide_cat": -1, "last_drawn_kind": PALETTE_SIZE - 1, "spans": _spans_from_pairs( [ln for j, ln in enumerate(linenos) if j % 2 == 0], 2)} @@ -1109,9 +1177,13 @@ def program_parts(geometry: "ProgramGeometry", colors: dict[str, Any], {"name": "program", "kind": "program_array", "planes": planes, "attrs": geometry.attrs, "palettes": palettes, "plane_offsets": offsets, - # The program is the buffer that nominates a dash and a hidden kind, - # and both are its rapid code. Every other buffer nominates neither. - "dash_cat": KIND_TRAVERSE, "hide_cat": KIND_TRAVERSE, + # The program is the buffer that nominates a hidden kind - its rapid + # code - and every other buffer nominates none. Rapids draw solid: + # LinuxCNC removed GL_LINE_STIPPLE from the rapid traverse and the + # soft-limit wireframe deliberately (f1c1209f52, "unreliable on some + # graphics cards"), so there is nothing left in this renderer that + # dashes, and no attribute or uniform to support it. + "hide_cat": KIND_TRAVERSE, "last_drawn_kind": LAST_DRAWN_KIND, # Passed as a callable, not as the index itself: ``index`` builds on # first mention, and the only thing that reads it is the highlight. @@ -1143,6 +1215,20 @@ def _coalesce_spans(keys: npt.NDArray[np.int64], firsts: npt.NDArray[np.int64], (firsts[tails] + counts[tails] - firsts[heads]).astype(np.int32)) +def _any_rotary_change(p1: Float64Points, p2: Float64Points) -> bool: + """Whether any move turns A, B or C. + + Short-circuits per column, so a 3-axis batch answers no in three + comparisons and never builds the per-move subdivision count at all. The + equality it tests is the same one :func:`_rotary_steps_batch` reduces, so + the two cannot disagree about whether a batch turns. + """ + for col in (3, 4, 5): + if not np.array_equal(p1[:, col], p2[:, col]): + return True + return False + + def _rotary_steps_batch(p1: Float64Points, p2: Float64Points) -> npt.NDArray[np.int64]: """Points each move contributes: 1, or the rotary subdivision count. @@ -1292,10 +1378,10 @@ def ring(i: float) -> tuple[float, float]: # The backplot is not program geometry: it is the path the machine has actually # travelled, streamed out of the C position logger's ring buffer while a # program runs, and re-uploaded a tail at a time. It shares the program's -# *shader* and its 20-byte palette-indexed vertex (:data:`TrajectoryVerts`), +# *shader* and its 16-byte palette-indexed vertex (:data:`TrajectoryVerts`), # and nothing else - it reads no ``ProgramGeometry`` and is filled by no canon. -# The backplot's own packing convention for the 20-byte vertex's uint32 word: +# The backplot's own packing convention for the 16-byte vertex's uint32 word: # the palette index in the high byte, the rest zero. It is not a source line # number and never was - the backplot is neither picked nor highlighted - so # the field the program array gives to the line number is simply unused here. @@ -1439,9 +1525,9 @@ def backplot_vertices(raw: Any, npts: int, is_xyuv: bool, legacy positionlogger.call vertex stream (full stride vs half stride). With a :class:`ColorPalette`, each vertex carries its colour's palette - index in the shared 20-byte layout: ``(M, TRAJ_FLOATS_PER_VERTEX)``, the - index packed into the word whose line-number bits stay zero, distance - zero. The backplot is neither picked nor dashed, so neither is read. + index in the shared 16-byte layout: ``(M, TRAJ_FLOATS_PER_VERTEX)``, the + index packed into the word whose line-number bits stay zero. The backplot + is not picked, so those bits are never read. The palette is keyed on the **stored bytes**, not on the preview's colour table. The C resolves a motion type to a ``struct color`` of four uint8 diff --git a/lib/python/rs274/glcanon_gl.py b/lib/python/rs274/glcanon_gl.py index ac073da9fe6..fd604d965aa 100644 --- a/lib/python/rs274/glcanon_gl.py +++ b/lib/python/rs274/glcanon_gl.py @@ -4,10 +4,10 @@ # 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 + distance-along-line, driven by a single -# MVP uniform, with shader-side dashing). 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. +# (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 @@ -47,9 +47,10 @@ import numpy as np import numpy.typing as npt -from rs274.glcanon_bake import (ATTR_DTYPE, KIND_MASK, LineRanges, MeshVerts, - PLANE_DTYPE, PaletteRGBA, TrajectoryVerts, - WideVerts) +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 @@ -61,75 +62,69 @@ GL_DEBUG = os.environ.get("GLCANON_GL_DEBUG", "") not in ("", "0") # --------------------------------------------------------------------------- -# Interleaved per-vertex-colour layout. Each vertex is 9 float32: position(3) -# color-rgba(4) lineno(1) distance(1). +# 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 20-byte layout below instead - +# 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 stride here and -# the one in rs274.glcanon_bake are two views of that single statement. +# ``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. # --------------------------------------------------------------------------- -FLOATS_PER_VERTEX = 9 -VERTEX_STRIDE = FLOATS_PER_VERTEX * 4 # bytes +VERTEX_STRIDE = FLOATS_PER_VERTEX * 4 # bytes, 32 ATTR_POSITION = 0 ATTR_COLOR = 1 ATTR_LINENO = 2 -ATTR_DISTANCE = 3 # (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), - (ATTR_DISTANCE, 1, 8 * 4), ) # --------------------------------------------------------------------------- -# The program trajectory's narrower layout: position(3 float32), a uint32 with -# the source line number and the draw category packed together, and distance- -# along-line (float32). 20 bytes, against 36 for the layout above - colour is -# not stored per vertex but looked up from a palette indexed by the category. +# 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 21 bytes, which pads to 24; packing keeps it at 20. +# at 17 bytes, which pads to 20; packing keeps it at 16. # # ``TrajectoryVerts``, imported above, is this layout as a type. # --------------------------------------------------------------------------- -TRAJ_FLOATS_PER_VERTEX = 5 -TRAJ_VERTEX_STRIDE = TRAJ_FLOATS_PER_VERTEX * 4 # bytes +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), - (ATTR_DISTANCE, 1, 4 * 4, GL_FLOAT), ) # --------------------------------------------------------------------------- -# The program array's layout: 24 bytes per vertex in two buffers rather than +# 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 + distance float32 16 B, per plane +# 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 positions -# and their dash distances differ (the transform differs, and dash phase -# accumulates along transformed points), 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. +# 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), - (ATTR_DISTANCE, 1, 3 * 4, GL_FLOAT), ) PROGRAM_ATTR_ATTRIBUTES = ( (ATTR_LINENO, 1, 0, GL_UNSIGNED_INT), @@ -147,32 +142,27 @@ # 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 7. +# GL 3.3 core and GLES 3.0 both guarantee 16 vertex attributes; this reaches 5. # --------------------------------------------------------------------------- ATTR_POSITION_B = 5 ATTR_COLOR_B = 6 -ATTR_DISTANCE_B = 7 -#: Per-vertex-colour (36-byte) layout, split into the two endpoints. +#: Per-vertex-colour (32-byte) layout, split into the two endpoints. WIDE_LINE_A_ATTRIBUTES = ( (ATTR_POSITION, 3, 0), (ATTR_COLOR, 4, 3 * 4), - (ATTR_DISTANCE, 1, 8 * 4), ) WIDE_LINE_B_ATTRIBUTES = ( (ATTR_POSITION_B, 3, 0), (ATTR_COLOR_B, 4, 3 * 4), - (ATTR_DISTANCE_B, 1, 8 * 4), ) #: Program-array plane buffer, split into the two endpoints. WIDE_PLANE_A_ATTRIBUTES = ( (ATTR_POSITION, 3, 0), - (ATTR_DISTANCE, 1, 3 * 4), ) WIDE_PLANE_B_ATTRIBUTES = ( (ATTR_POSITION_B, 3, 0), - (ATTR_DISTANCE_B, 1, 3 * 4), ) #: Program-array attribute buffer. Bound at the segment's **end** vertex only: #: that is how the expanded path reproduces the last-vertex convention the @@ -451,8 +441,6 @@ def pending_line_expansion() -> float: #: 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. -#: highp also keeps v_distance exact far into a large program, where a mediump -#: dash phase would visibly drift. GLSL_ES_PREAMBLE = """#version 300 es precision highp float; precision highp int; @@ -685,27 +673,20 @@ def ctypes_offset(byte_offset: int) -> Any: layout(location = 0) in vec3 in_position; layout(location = 1) in vec4 in_color; layout(location = 2) in float in_lineno; -layout(location = 3) in float in_distance; uniform mat4 u_mvp; out vec4 v_color; -out float v_distance; void main() { gl_Position = u_mvp * vec4(in_position, 1.0); v_color = in_color; - v_distance = in_distance; } """ LINE_FRAGMENT_SHADER = """ in vec4 v_color; -in float v_distance; -uniform bool u_dashed; // enable shader dashing on the distance attribute -uniform float u_dash_period; // world units for one on+off cycle -uniform float u_dash_duty; // fraction of the period drawn (0..1) 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; @@ -713,8 +694,6 @@ def ctypes_offset(byte_offset: int) -> Any: out vec4 frag_color; void main() { - if (u_dashed && fract(v_distance / u_dash_period) > u_dash_duty) - discard; vec4 c = u_use_override ? u_override_color : v_color; frag_color = vec4(c.rgb, c.a * u_alpha); } @@ -774,22 +753,17 @@ def ctypes_offset(byte_offset: int) -> Any: WIDE_LINE_VERTEX_SHADER = """ layout(location = 0) in vec3 in_position; layout(location = 1) in vec4 in_color; -layout(location = 3) in float in_distance; layout(location = 5) in vec3 in_position_b; layout(location = 6) in vec4 in_color_b; -layout(location = 7) in float in_distance_b; uniform mat4 u_mvp; out vec4 v_color; -out float v_distance; %(expand)s void main() { vec4 ca = u_mvp * vec4(in_position, 1.0); vec4 cb = u_mvp * vec4(in_position_b, 1.0); - bool at_b = gl_VertexID > 1; - v_color = at_b ? in_color_b : in_color; - v_distance = at_b ? in_distance_b : in_distance; + v_color = (gl_VertexID > 1) ? in_color_b : in_color; gl_Position = expand(ca, cb); } """ % {"expand": WIDE_LINE_EXPAND} @@ -798,7 +772,7 @@ def ctypes_offset(byte_offset: int) -> Any: class LineProgram: """The line shader plus its default uniform state. - A draw call is: :meth:`use`, set ``u_mvp`` (and any dashing/alpha/override + 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. @@ -821,19 +795,11 @@ def begin(self, mvp: Any, alpha: float = 1.0) -> None: self.shader.use() self.shader.set_mat4("u_mvp", mvp) self.shader.set_float("u_alpha", alpha) - self.shader.set_bool("u_dashed", False) self.shader.set_bool("u_use_override", False) def set_alpha(self, alpha: float) -> None: self.shader.set_float("u_alpha", alpha) - def set_dashed(self, enabled: bool, period: float = 1.0, - duty: float = 0.5) -> None: - self.shader.set_bool("u_dashed", enabled) - if enabled: - self.shader.set_float("u_dash_period", period) - self.shader.set_float("u_dash_duty", duty) - def set_override_color(self, rgba: Sequence[float] | None) -> None: if rgba is None: self.shader.set_bool("u_use_override", False) @@ -865,7 +831,7 @@ def set_expansion(self, viewport: Sequence[float], width: float) -> None: # 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 20-byte vertex, so it gets its own pair of stages. +# 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 @@ -876,50 +842,39 @@ def set_expansion(self, viewport: Sequence[float], width: float) -> None: TRAJ_VERTEX_SHADER = """ layout(location = 0) in vec3 in_position; layout(location = 2) in uint in_packed; // lineno | category << 24 -layout(location = 3) in float in_distance; uniform mat4 u_mvp; flat out uint v_kind; -out float v_distance; void main() { gl_Position = u_mvp * vec4(in_position, 1.0); v_kind = in_packed >> 24u; - v_distance = in_distance; } """ # The program array's vertex stage. Same outputs as the packed one above, off -# the two-buffer 24-byte layout: the line number is its own uint32 attribute +# 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 = 3) in float in_distance; layout(location = 4) in uint in_kindtool; uniform mat4 u_mvp; flat out uint v_kind; -out float v_distance; void main() { gl_Position = u_mvp * vec4(in_position, 1.0); v_kind = in_kindtool & %(kind_mask)uu; - v_distance = in_distance; } """ % {"kind_mask": KIND_MASK} TRAJ_FRAGMENT_SHADER = """ flat in uint v_kind; -in float v_distance; uniform vec4 u_palette[%(palette)d]; -uniform bool u_dashed; // enable dashing of the dash kind -uniform float u_dash_period; // world units for one on+off cycle -uniform float u_dash_duty; // fraction of the period drawn (0..1) uniform float u_alpha; // multiplies the palette alpha (program_alpha) -uniform int u_dash_cat; // kind that dashes, -1 for none 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) @@ -939,22 +894,15 @@ def set_expansion(self, viewport: Sequence[float], width: float) -> None: if (cat > u_last_drawn_kind) discard; - // Which kind dashes and which is hidden are the drawing buffer's to - // nominate, not properties of the number zero: the program nominates its - // rapid code for both, the dwell markers and the live backplot nominate - // neither and so are never dashed or discarded whatever code their - // vertices carry. The highlight pass overrides both - it draws the - // selected line solid, and draws it whether or not rapids are shown, + // 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) { - if (cat == u_hide_cat) - discard; - if (cat == u_dash_cat - && u_dashed - && fract(v_distance / u_dash_period) > u_dash_duty) - discard; - } + 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); } @@ -981,9 +929,9 @@ def _pin_provoking_vertex() -> None: class TrajectoryProgram: """The trajectory shader plus its palette and pass state. - Two vertex stages share one fragment stage: the packed 20-byte layout the - live backplot uses, and the program array's 24-byte one. They differ only - in where the kind comes from, so the colouring, dashing and record-kind + 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. """ @@ -998,11 +946,10 @@ def use(self) -> None: self.shader.use() def begin(self, mvp: Any, palette: Any, alpha: float = 1.0, - dash_cat: int = -1, hide_cat: int = -1, dashed: bool = True, - dash_period: float = 1.0, dash_duty: float = 0.5, + hide_cat: int = -1, last_drawn_kind: int = PALETTE_SIZE - 1) -> None: - """Start a pass. ``dash_cat``/``hide_cat`` are the drawing buffer's own - kind codes, or -1 for "this buffer has 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 @@ -1012,12 +959,8 @@ def begin(self, mvp: Any, palette: Any, alpha: float = 1.0, self.shader.set_mat4("u_mvp", mvp) self.set_palette(palette) self.shader.set_float("u_alpha", alpha) - self.shader.set_int("u_dash_cat", dash_cat) 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_dashed", dashed) - self.shader.set_float("u_dash_period", dash_period) - self.shader.set_float("u_dash_duty", dash_duty) self.shader.set_bool("u_use_override", False) def set_palette(self, palette: Any) -> None: @@ -1141,8 +1084,7 @@ def pack_depth_rgba8(values: Any) -> bytes: 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. Deliberately *not* the dash rejection: a rapid was pickable in - // the gaps of its dash pattern before, and still is. + // geometry. int cat = int(v_kind); if (cat > u_last_drawn_kind) discard; @@ -1197,28 +1139,24 @@ def delete(self) -> None: # body; see the WIDE_LINE_EXPAND note. PROGRAM_WIDE_VERTEX_SHADER = """ layout(location = 0) in vec3 in_position; -layout(location = 3) in float in_distance; layout(location = 4) in uint in_kindtool; // the segment's END vertex layout(location = 5) in vec3 in_position_b; -layout(location = 7) in float in_distance_b; uniform mat4 u_mvp; flat out uint v_kind; -out float v_distance; %(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; - v_distance = (gl_VertexID > 1) ? in_distance_b : in_distance; gl_Position = expand(ca, cb); } """ % {"expand": WIDE_LINE_EXPAND, "kind_mask": KIND_MASK} class ProgramArrayProgram(TrajectoryProgram): - """:class:`TrajectoryProgram` over the program array's 24-byte layout.""" + """:class:`TrajectoryProgram` over the program array's 20-byte layout.""" VERTEX_SHADER = PROGRAM_VERTEX_SHADER @@ -1251,7 +1189,7 @@ class ProgramArrayPickProgram(TrajectoryPickProgram): # # 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 36-byte vertex - which cannot happen with the two colours +# 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. # --------------------------------------------------------------------------- @@ -1616,7 +1554,6 @@ def upload(self, parts: Sequence[dict[str, Any]]) -> None: if kind == "program_array": buf.upload(part["planes"], part["attrs"], part.get("palettes", ()), - dash_cat=part.get("dash_cat", -1), hide_cat=part.get("hide_cat", -1), last_drawn_kind=part.get("last_drawn_kind", PALETTE_SIZE - 1), @@ -1629,7 +1566,6 @@ def upload(self, parts: Sequence[dict[str, Any]]) -> None: part.get("firsts", empty), part.get("counts", empty), part["ranges"], part["palette"], - dash_cat=part.get("dash_cat", -1), hide_cat=part.get("hide_cat", -1), mode=primitive_mode(part.get("mode"))) else: @@ -1639,19 +1575,17 @@ def upload(self, parts: Sequence[dict[str, Any]]) -> None: self.buffers.pop(name).delete() def draw(self, renderer: GlCanonRenderer, mvp: Any, - show_rapids: bool = True, alpha: float = 1.0, - dash_period: float = 1.0, dash_duty: float = 0.5) -> None: + 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 - dashes and which the show-rapids toggle hides, so a buffer that - nominates neither is unaffected by either. + 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, - dash_period, dash_duty) + buf.begin(renderer, mvp, alpha, show_rapids) buf.draw() def draw_line(self, renderer: GlCanonRenderer, mvp: Any, @@ -1852,9 +1786,8 @@ def pick_target(self, w: int, h: int) -> PickTarget: # -- transient line geometry (grid/axes/extents/limits/labels) --------- def draw_line_array(self, mvp: Any, verts: WideVerts, - dashed: bool = False, dash_period: float = 1.0, alpha: float = 1.0) -> None: - """Draw an interleaved (N,9) line array through the line shader. + """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 @@ -1879,19 +1812,17 @@ def draw_line_array(self, mvp: Any, verts: WideVerts, if width: wide = self.wide_line_program() wide.begin(mvp, alpha) - wide.set_dashed(dashed, dash_period) wide.set_expansion(self._viewport, width) self._scratch.draw_wide() return line = self.line_program() line.begin(mvp, alpha) - line.set_dashed(dashed, dash_period) 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,9) array as flat primitives (default + """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) @@ -1955,7 +1886,7 @@ def delete(self) -> None: class ProgramArrayBuffers: - """A buffer in the program array's 24-byte format. + """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 @@ -1992,11 +1923,10 @@ def __init__(self, mode: GLEnum = GL_LINE_STRIP) -> None: #: 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 dashes, 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.dash_cat = -1 + # 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 @@ -2010,7 +1940,7 @@ def __init__(self, mode: GLEnum = GL_LINE_STRIP) -> None: def upload(self, planes: Sequence[Any], attrs: Any, palettes: Sequence[Sequence[Sequence[float]]] = (), - dash_cat: int = -1, hide_cat: int = -1, + hide_cat: int = -1, last_drawn_kind: int = PALETTE_SIZE - 1, mode: GLEnum | None = None, spans: Any = None, @@ -2020,7 +1950,6 @@ def upload(self, planes: Sequence[Any], attrs: Any, self.mode = mode attrs = np.ascontiguousarray(attrs, dtype=ATTR_DTYPE) self.count = int(len(attrs)) - self.dash_cat = dash_cat self.hide_cat = hide_cat self.last_drawn_kind = last_drawn_kind self.spans = spans @@ -2063,10 +1992,8 @@ def upload(self, planes: Sequence[Any], attrs: Any, # pattern stays the one every other buffer here uses. def begin(self, renderer: GlCanonRenderer, mvp: Any, alpha: float = 1.0, - show_rapids: bool = True, dash_period: float = 1.0, - dash_duty: float = 0.5) -> None: - self._pass = ("draw", renderer, mvp, alpha, show_rapids, dash_period, - dash_duty) + 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: @@ -2076,8 +2003,8 @@ def begin_override(self, renderer: GlCanonRenderer, mvp: Any, color: Sequence[float]) -> None: """One flat colour: the highlight. - No dash and no hidden kind - a highlighted rapid draws solid, and - draws while rapids are hidden. ``last_drawn_kind`` is *not* relaxed: + 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) @@ -2124,16 +2051,13 @@ def _use(self, plane: int, wide: float = 0.0) -> None: program = (renderer.wide_program_array_program() if wide else renderer.program_array_program()) if what == "override": - program.begin(mvp, palette, dash_cat=-1, hide_cat=-1, + 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, dash_period, dash_duty = self._pass + _w, _r, _m, alpha, show_rapids = self._pass program.begin(mvp, palette, alpha, - dash_cat=self.dash_cat, hide_cat=-1 if show_rapids else self.hide_cat, - dashed=True, dash_period=dash_period, - dash_duty=dash_duty, last_drawn_kind=self.last_drawn_kind) if wide: program.set_expansion(renderer.viewport, wide) @@ -2258,7 +2182,7 @@ def delete(self) -> None: class TrajectoryBuffers: - """A buffer in the shared 20-byte vertex format, drawn through the + """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 @@ -2283,18 +2207,16 @@ def __init__(self, mode: GLEnum = GL_LINE_STRIP) -> None: 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 dashes, and which one 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.dash_cat = -1 + # 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, - dash_cat: int = -1, hide_cat: int = -1, + hide_cat: int = -1, mode: GLEnum | None = None) -> None: if mode is not None: self.mode = mode @@ -2303,7 +2225,6 @@ def upload(self, verts: TrajectoryVerts, firsts: Any, counts: Any, self.firsts = np.ascontiguousarray(firsts, dtype=np.int32) self.counts = np.ascontiguousarray(counts, dtype=np.int32) self.line_ranges = line_ranges or {} - self.dash_cat = dash_cat self.hide_cat = hide_cat if palette is not None: self.palette = palette @@ -2315,20 +2236,16 @@ def upload(self, verts: TrajectoryVerts, firsts: Any, counts: Any, self._configured = True def begin(self, renderer: GlCanonRenderer, mvp: Any, alpha: float = 1.0, - show_rapids: bool = True, dash_period: float = 1.0, - dash_duty: float = 0.5) -> None: + show_rapids: bool = True) -> None: """Configure the trajectory shader for this buffer's own draw. - The buffer nominates which of its categories dashes and which the - show-rapids toggle hides, so a buffer nominating neither is unaffected - by either. The shader programs stay shared, hence ``renderer``. + 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, - dash_cat=self.dash_cat, - hide_cat=-1 if show_rapids else self.hide_cat, - dashed=True, dash_period=dash_period, - dash_duty=dash_duty) + hide_cat=-1 if show_rapids else self.hide_cat) def begin_ids(self, renderer: GlCanonRenderer, mvp: Any, show_rapids: bool = True) -> None: @@ -2344,12 +2261,11 @@ 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 dash and no hidden category: a highlighted rapid draws solid, and - # draws even while rapids are hidden. That held before because - # ``u_use_override`` short-circuited the category test; leaving both at - # -1 says it a second way, so the behaviour does not rest on the - # override alone. - traj.begin(mvp, self.palette, dash_cat=-1, hide_cat=-1) + # 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: @@ -2401,7 +2317,7 @@ class CategoryBuffers: 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 20-byte vertex + 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 @@ -2467,13 +2383,12 @@ def draw_wide(self) -> None: vao.unbind() def begin(self, renderer: GlCanonRenderer, mvp: Any, alpha: float = 1.0, - show_rapids: bool = True, dash_period: float = 1.0, - dash_duty: float = 0.5) -> None: + 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 dash or show-rapids arguments to select: they - are accepted and ignored, which is the whole of this kind's answer. + 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) @@ -2527,7 +2442,7 @@ class BackplotRing: 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 20-byte vertex with a palette index - ``indexed`` - drawn + 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. @@ -2549,7 +2464,7 @@ def __init__(self, palette: Any = None) -> None: self.capacity = 0 # vertices the store can hold self.count = 0 # vertices to draw self.mode: GLEnum = GL_LINE_STRIP - self.indexed = True # 20-byte palette-indexed layout + 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 @@ -2690,12 +2605,11 @@ def draw(self, renderer: GlCanonRenderer, mvp: Any, if self.count < 2: return if self.indexed: - # The trail nominates no dash and no hidden category, so a point - # whose palette index happens to equal the program's rapid code is - # neither dashed nor hidden with rapids off. + # 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, - dash_cat=-1, hide_cat=-1, dashed=False) + mvp, self.shader_palette or [], alpha, hide_cat=-1) else: renderer.line_program().begin(mvp, alpha) self._draw_arrays() diff --git a/lib/python/rs274/glcanon_scene.py b/lib/python/rs274/glcanon_scene.py index 6cc6e30a535..1f985453b31 100644 --- a/lib/python/rs274/glcanon_scene.py +++ b/lib/python/rs274/glcanon_scene.py @@ -102,6 +102,15 @@ def minmax(*args: float) -> tuple[float, float]: #: GLCANON_SCENE_DEBUG=1 logs the drawn/skipped part split each time it changes SCENE_DEBUG = bool(os.environ.get("GLCANON_SCENE_DEBUG")) +#: OpenGL's default ``GL_LIGHT_MODEL_AMBIENT`` - the scene-wide ambient every +#: lit surface receives on top of any light's own ambient. The fixed-function +#: tool marker this renderer replaced never set it, so it took the default, +#: and its material ambient is ``(1,1,1)`` - which makes this a flat addition +#: to ``tool_ambient``. Not a colour-table entry, because it was never one: +#: it is a property of the pipeline being reproduced. See +#: :meth:`Primitives.draw_cone`. +LIGHT_MODEL_AMBIENT = 0.2 + # View ports coordinates VX = 0 VY = 1 @@ -414,7 +423,7 @@ def __init__(self, hershey: Any = None) -> None: @staticmethod def lines_to_array(points: LineEndpoints, color: Color, alpha: float = 1.0) -> WideVerts: - """Pack a flat list of (x,y,z) GL_LINES endpoints into an (N,9) array.""" + """Pack a flat list of (x,y,z) GL_LINES endpoints into an (N,8) array.""" n = len(points) arr = np.zeros((n, glcanon_bake.FLOATS_PER_VERTEX), dtype=np.float32) if n: @@ -500,13 +509,28 @@ def draw_cone(self, ctx: FrameContext, color: Color, current model-view stack transform (position/rotation/scale already applied). Eye-space normals come from the stack's 3x3, so the blend state the caller set still applies. ``mesh_verts`` overrides the cone - mesh (e.g. a cylinder for a large-diameter tool).""" + mesh (e.g. a cylinder for a large-diameter tool). + + The ambient term is ``LIGHT_MODEL_AMBIENT + tool_ambient``, not + ``tool_ambient`` alone: the fixed-function pipeline this replaces lit + the marker with *two* ambient contributions, the light's own + (``glLightfv(GL_LIGHT0, GL_AMBIENT, tool_ambient)``) and the global + light model's, and the material ambient it multiplies is ``(1,1,1)``. + Dropping the global one is a visible 0.2 off the marker's darkest + faces - 153 to 102 on the default colours. + + Saturation is left to the framebuffer write, as it is in fixed + function: with the default colours the lit faces reach 1.2 and clamp + there. An explicit clamp here would be a second one. + """ mv = ctx.mv.top() + ambient = tuple(c + LIGHT_MODEL_AMBIENT + for c in ctx.colors['tool_ambient']) ctx.renderer.draw_cone( ctx.mv.mvp(), mv[:3, :3], tuple(color) + (1.0,), mesh_verts=self.cone_mesh() if mesh_verts is None else mesh_verts, light_dir=(1.0, -1.0, 1.0), - ambient=ctx.colors['tool_ambient'], + ambient=ambient, diffuse=ctx.colors['tool_diffuse']) self._unbind() @@ -561,10 +585,15 @@ def apply_baseline(self) -> None: Most parts want exactly this and so have an empty ``scope()``; a part wanting something else sets it in its scope and puts this back. - Blending stays ON: the baked program colours carry a per-category - alpha (traverse 1/3, arcs 1/2), so they are meant to be composited. - Opaque geometry with alpha 1 is unaffected by it, and disabling blend - here is NOT pixel-neutral. + Blending is ON here because the parts that composite outnumber the + ones that must not: the live backplot's colours carry their own alpha, + and geometry with alpha 1 is unaffected either way. It is NOT a + statement that everything drawn is meant to composite. The program is + the case that is not - its baked per-category alpha reaches the + framebuffer only when the alpha toggle says so - and it turns blending + off in its own scope, which is where a part's non-baseline state + belongs. Choosing the baseline to suit one part instead would make + that part's requirement invisible at the point it applies. """ glEnable(GL_DEPTH_TEST) glDepthFunc(GL_LESS) @@ -851,32 +880,54 @@ def upload(self, ctx: FrameContext) -> None: @contextmanager def program_alpha(ctx: FrameContext) -> Iterator[None]: - """Composite the program translucently when the toggle wants it: no depth - test, plain source-alpha blend. + """The toggle's compositing, in *both* of its states. + + The baked colours carry a per-category alpha (traverse 1/3, feed 1/3, arcs + 1/2), and whether that alpha reaches the framebuffer is the toggle's to + decide - exactly as it was when the colour was a ``glColor4f`` and the + toggle was the ``glEnable(GL_BLEND)`` around the display lists. So: + + ========== ============================= ==================== + toggle depth blend + ========== ============================= ==================== + on off, so every line composites baseline (on) + off baseline (on) off, alpha discarded + ========== ============================= ==================== + + Blend-off is what makes the default view opaque. Without it the alpha is + applied unconditionally and white feed lines land at 1/3 grey - and, on a + software rasteriser, every fragment becomes a read-modify-write of the + colour buffer that no depth rejection can skip. + + Each branch restores what it changed and nothing else, so the frame's + baseline is in effect again for the parts drawn after the program - the + live backplot among them, whose own alpha is not this toggle's business. A property of how the program sits in the rest of the scene, not of its own geometry - which is why both parts drawn from the program's buffers need it. Shared as a context manager the scopes compose with ``with``, rather than as a base class: composition, not inheritance. """ - if not ctx.program_alpha: - yield - return - glDisable(GL_DEPTH_TEST) - glEnable(GL_BLEND) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - try: - yield - finally: + if ctx.program_alpha: + glDisable(GL_DEPTH_TEST) + try: + yield + finally: + glEnable(GL_DEPTH_TEST) + else: glDisable(GL_BLEND) - glEnable(GL_DEPTH_TEST) + try: + yield + finally: + glEnable(GL_BLEND) class ProgramPart(Part): """The program's trajectory: traverse, feed and arc moves. - Rapids are dashed in the shader, at a period scaled to the program so the - dash count stays roughly view-independent. + Rapids draw solid, as they do in the pre-change renderer: nothing in this + renderer dashes, and there is no attribute or uniform left to do it with + (see ``glcanon_bake.program_parts``). """ def __init__(self, resource: ProgramResource | None = None) -> None: @@ -897,18 +948,7 @@ def invalidate(self) -> None: def draw(self, ctx: FrameContext) -> None: self.resource.ensure_uploaded(ctx) self.resource.buffers.draw(ctx.renderer, ctx.preview_mvp(), - show_rapids=ctx.show_rapids, alpha=1.0, - dash_period=self.dash_period(ctx)) - - @staticmethod - def dash_period(ctx: FrameContext) -> float: - """World-space dash period giving a roughly view-independent dash count.""" - if ctx.canon is not None: - span = max((a - b) for a, b in - zip(ctx.canon.max_extents, ctx.canon.min_extents)) - if span > 0: - return span / 60.0 - return 0.5 + show_rapids=ctx.show_rapids, alpha=1.0) class HighlightPart(Part): @@ -1652,7 +1692,18 @@ def _apply_rotary(ctx: FrameContext, rx: float, ry: float, @staticmethod def _draw_cone(ctx: FrameContext) -> None: - """The default marker, scaled to the program so it stays legible.""" + """The default marker, scaled to the program so it stays legible. + + Sets its own blend constant, as the two paths in :meth:`draw_solid` + do and as the legacy ``make_cone`` display list did. Without it the + constant is whatever GL was last left holding - 0 on a fresh context, + but ``tool_alpha`` or ``lathetool_alpha`` once a large-diameter or + lathe tool has been drawn - so the marker's pixels would depend on + which tool the session happened to load first. Under the caller's + GL_ONE/GL_CONSTANT_ALPHA blend this is what lets the geometry behind + the marker show through it. + """ + glBlendColor(0, 0, 0, ctx.colors['tool_alpha']) if ctx.canon and not ctx.disable_cone_scaling: g = ctx.canon From f78ed6fbaa6e1436aed4dc0047009d0a8d83a3cc Mon Sep 17 00:00:00 2001 From: Alexey Presnyakov <309782758+alex-pres@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:20:05 +0400 Subject: [PATCH 7/8] glcanon: give screens a GL-free way to offset the preview camera GlNavBase.modelview is now the only camera state and both consumers reload the GL modelview from it every frame, so any glTranslatef() a screen issued outside the camera was discarded on the next frame - the bundled plasmac table view had silently stopped centring. Add translate_modelview(), which post-multiplies exactly as glTranslatef() did, and convert the two bundled call sites. Retires the direct GL access the rewrite left behind: hershey and axis.py no longer import OpenGL, and emcmodule drops the epoxy link. --- configs/sim/axis/plasma/plasmac2/plasmac2.py | 2 +- lib/python/glnav.py | 16 ++ lib/python/hershey.py | 2 - lib/python/rs274/glcanon.py | 35 ++- .../screens/qtplasmac/qtplasmac_handler.py | 3 +- src/emc/usr_intf/axis/Submakefile | 2 +- src/emc/usr_intf/axis/extensions/emcmodule.cc | 224 ++++------------ src/emc/usr_intf/axis/scripts/axis.py | 2 - tests/glcanon/expected | 1 + tests/glcanon/test.sh | 12 + tests/glcanon/test_backplot_palette.py | 246 ++++++++++++++++++ tests/glcanon/test_camera_matrices.py | 144 ++++++++++ 12 files changed, 511 insertions(+), 178 deletions(-) create mode 100644 tests/glcanon/expected create mode 100755 tests/glcanon/test.sh create mode 100644 tests/glcanon/test_backplot_palette.py create mode 100644 tests/glcanon/test_camera_matrices.py diff --git a/configs/sim/axis/plasma/plasmac2/plasmac2.py b/configs/sim/axis/plasma/plasmac2/plasmac2.py index 006f30a3ba8..f9a71b2271b 100644 --- a/configs/sim/axis/plasma/plasmac2/plasmac2.py +++ b/configs/sim/axis/plasma/plasmac2/plasmac2.py @@ -854,7 +854,7 @@ def set_view_t(event=None, scale=None): xTableCenter = (machineBounds['X-'] + (machineBounds['xLen'] / 2)) / mult yTableCenter = (machineBounds['Y-'] + (machineBounds['yLen'] / 2)) / mult o.reset() - glTranslatef(-xTableCenter, -yTableCenter, 0) + o.translate_modelview(-xTableCenter, -yTableCenter, 0) o.set_eyepoint_from_extents(xTableLength, yTableLength) o.perspective = False o.lat = o.lon = 0 diff --git a/lib/python/glnav.py b/lib/python/glnav.py index 9c624b4aeca..2178bf99d5f 100644 --- a/lib/python/glnav.py +++ b/lib/python/glnav.py @@ -378,6 +378,22 @@ def get_projection_matrix(self, width, height): def get_modelview_matrix(self): return self.modelview.copy() + def translate_modelview(self, x, y, z): + """Compose a translation onto the camera's modelview matrix. + + This is the supported replacement for the legacy ``glTranslatef()`` + against the fixed-function matrix stack: both camera consumers reload + the GL modelview from this matrix every frame, so a translation issued + outside the camera is discarded. The translation is post-multiplied, + matching what ``glTranslatef()`` did to the current matrix, so call + sites port with no numeric change. + + Deliberately does not redraw. This is a composition primitive rather + than a settled camera state, and callers follow it with + ``set_eyepoint*()`` or an explicit refresh, which redraws anyway. + """ + self.modelview = multiply(self.modelview, translation_matrix(x, y, z)) + def set_view_x(self): self.reset() self.modelview = multiply(self.modelview, rotation_matrix(-90, 0, 1, 0), rotation_matrix(-90, 1, 0, 0)) diff --git a/lib/python/hershey.py b/lib/python/hershey.py index 9cae15e9149..39b62ea563e 100644 --- a/lib/python/hershey.py +++ b/lib/python/hershey.py @@ -16,8 +16,6 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import itertools -from OpenGL.GL import * -from OpenGL.GLU import * translate = {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, '-': 10, '.': 11, 'X': 12, 'Y': 13, 'Z': 14, 'G': 15, 'U': 16, 'V': 17, 'W': 18} diff --git a/lib/python/rs274/glcanon.py b/lib/python/rs274/glcanon.py index 7537ecf3026..eedbd6cfd2d 100644 --- a/lib/python/rs274/glcanon.py +++ b/lib/python/rs274/glcanon.py @@ -29,6 +29,7 @@ import gcode import numpy as np import os +import warnings from functools import reduce # Axis views, viewport coordinates and the DRO home/limit icons now live with @@ -540,9 +541,39 @@ def highlight(self, lineno, geometry): return x, y, z def color_with_alpha(self, colorname): - glColor4f(*(self.colors[colorname] + (self.colors.get(colorname+'_alpha', 1/3.),))) + """Retired: set the fixed-function current colour. Does nothing. + + The renderer resolves per-kind colours in rs274.glcanon_bake and feeds + them to the shaders, so there is no current colour left to set. Kept + as an inert shim because it is a public method and out-of-tree screens + may still call it, where it would otherwise raise GLError under a core + context. + """ + global _warned_color_with_alpha + if not _warned_color_with_alpha: + _warned_color_with_alpha = True + warnings.warn("GlCanonDraw.color_with_alpha() no longer draws; the " + "renderer resolves per-kind colours in rs274.glcanon_bake", + DeprecationWarning, stacklevel=2) + def color(self, colorname): - glColor3f(*self.colors[colorname]) + """Retired: set the fixed-function current colour. Does nothing. + + See color_with_alpha(). + """ + global _warned_color + if not _warned_color: + _warned_color = True + warnings.warn("GlCanonDraw.color() no longer draws; the renderer " + "resolves per-kind colours in rs274.glcanon_bake", + DeprecationWarning, stacklevel=2) + +# Warn once per process per method. A module-level flag rather than the +# warnings filter, so a GUI that has installed its own filter still gets +# exactly one notice — and so these per-frame draw-path methods cost a branch +# rather than the warning machinery on every call. +_warned_color_with_alpha = False +_warned_color = False def with_context(f): def inner(self, *args, **kw): diff --git a/share/qtvcp/screens/qtplasmac/qtplasmac_handler.py b/share/qtvcp/screens/qtplasmac/qtplasmac_handler.py index 1f56d4e74b1..dfdaeab8c85 100644 --- a/share/qtvcp/screens/qtplasmac/qtplasmac_handler.py +++ b/share/qtvcp/screens/qtplasmac/qtplasmac_handler.py @@ -35,7 +35,6 @@ import glob import linuxcnc import hal -from OpenGL.GL import glTranslatef from qtpy import QtCore, QtWidgets, QtGui from qtpy.QtCore import * from qtpy.QtWidgets import * @@ -2125,7 +2124,7 @@ def view_t_pressed(self, widget): yTableCenter = (self.yMin + self.yLen / 2) / mult - mid[1] xSize = self.xLen / mult / zoomScale ySize = self.yLen / mult / zoomScale - glTranslatef(-xTableCenter, -yTableCenter, 0) + widget.translate_modelview(-xTableCenter, -yTableCenter, 0) widget.set_eyepoint_from_extents(xSize, ySize) widget.perspective = False widget.lat = widget.lon = 0 diff --git a/src/emc/usr_intf/axis/Submakefile b/src/emc/usr_intf/axis/Submakefile index 3b294a2c91e..158864a259d 100644 --- a/src/emc/usr_intf/axis/Submakefile +++ b/src/emc/usr_intf/axis/Submakefile @@ -17,7 +17,7 @@ $(call TOOBJSDEPS, $(TKDARMODULESRCS)) : EXTRAFLAGS = $(TCL_CFLAGS) $(EMCMODULE): $(call TOOBJS, $(EMCMODULESRCS)) ../lib/liblinuxcnc.a ../lib/libnml.so.0 \ ../lib/liblinuxcncini.so ../lib/libtooldata.so.0 $(ECHO) Linking python module $(notdir $@) - $(Q)$(CXX) $(LDFLAGS) -shared -o $@ $^ -L/usr/X11R6/lib -lm -lepoxy + $(Q)$(CXX) $(LDFLAGS) -shared -o $@ $^ -L/usr/X11R6/lib -lm $(TOGLMODULE): $(call TOOBJS, $(TOGLMODULESRCS)) $(ECHO) Linking python module $(notdir $@) diff --git a/src/emc/usr_intf/axis/extensions/emcmodule.cc b/src/emc/usr_intf/axis/extensions/emcmodule.cc index 406018f3f83..19fdeddf63b 100644 --- a/src/emc/usr_intf/axis/extensions/emcmodule.cc +++ b/src/emc/usr_intf/axis/extensions/emcmodule.cc @@ -39,8 +39,6 @@ #include -#include -#include #include using namespace linuxcnc; @@ -2657,64 +2655,12 @@ static void vertex9(const double pt[9], double p[3], const char *geometry) { } } -// Deprecated: uses immediate-mode OpenGL (glVertex3dv), which is removed in -// the 3.3 core profile now used for preview rendering. -static void glvertex9(const double pt[9], const char *geometry) { - double p[3]; - vertex9(pt, p, geometry); - glVertex3dv(p); -} - -// Deprecated: emits immediate-mode OpenGL vertices via glvertex9(), invalid -// in the 3.3 core profile now used for preview rendering. -static void line9(const double p1[9], const double p2[9], const char *geometry) { - if(p1[3] != p2[3] || p1[4] != p2[4] || p1[5] != p2[5]) { - double dc = std::max({ - fabs(p2[3] - p1[3]), - fabs(p2[4] - p1[4]), - fabs(p2[5] - p1[5])}); - int st = (int)ceil(std::max(10.0, dc/10)); - int i; - - for(i=1; i<=st; i++) { - double t = i * 1.0 / st; - double v = 1.0 - t; - double pt[9]; - for(int j=0; j<9; j++) { pt[j] = t * p2[j] + v * p1[j]; } - glvertex9(pt, geometry); - } - } else { - glvertex9(p2, geometry); - } -} - -// Deprecated: emits immediate-mode OpenGL vertices via glvertex9(), invalid -// in the 3.3 core profile now used for preview rendering. -static void line9b(const double p1[9], const double p2[9], const char *geometry) { - glvertex9(p1, geometry); - if(p1[3] != p2[3] || p1[4] != p2[4] || p1[5] != p2[5]) { - double dc = std::max({ - fabs(p2[3] - p1[3]), - fabs(p2[4] - p1[4]), - fabs(p2[5] - p1[5])}); - int st = (int)ceil(std::max(10.0, dc/10)); - int i; - - for(i=1; i<=st; i++) { - double t = i * 1.0 / st; - double v = 1.0 - t; - double pt[9]; - for(int j=0; j<9; j++) { pt[j] = t * p2[j] + v * p1[j]; } - glvertex9(pt, geometry); - if(i != st) - glvertex9(pt, geometry); - } - } else { - glvertex9(p2, geometry); - } -} - +// Retired: this emitted immediate-mode OpenGL vertices, which the 3.3 core +// profile used for preview rendering does not have. The name, signature, +// argument checking, and return value are kept for out-of-tree callers; it no +// longer draws. Replacement: rs274.glcanon_scene. static PyObject *pyline9(PyObject * /*s*/, PyObject *o) { + static bool warned = false; double pt1[9], pt2[9]; const char *geometry; @@ -2728,7 +2674,13 @@ static PyObject *pyline9(PyObject * /*s*/, PyObject *o) { &pt2[6], &pt2[7], &pt2[8])) return NULL; - line9b(pt1, pt2, geometry); + if(!warned) { + warned = true; + if(PyErr_WarnEx(PyExc_DeprecationWarning, + "linuxcnc.line9() no longer draws; use rs274.glcanon_scene", + 1) < 0) + return NULL; + } Py_RETURN_NONE; } @@ -2772,15 +2724,17 @@ static PyObject *pygui_rot_offsets(PyObject * /*s*/, PyObject *o) { return Py_None; } -// Deprecated: draws with immediate-mode OpenGL (glBegin/glVertex*/glEnd), -// invalid in the 3.3 core profile now used for preview rendering. +// Retired: this drew with immediate-mode OpenGL (glBegin/glVertex*/glEnd), +// invalid in the 3.3 core profile now used for preview rendering. The name, +// signature, argument checking, and return value are kept for out-of-tree +// callers; it no longer draws. Replacement: rs274.glcanon_scene. static PyObject *pydraw_lines(PyObject * /*s*/, PyObject *o) { + static bool warned = false; PyListObject *li; int for_selection = 0; int i; - int first = 1; - int nl = -1, n; - double p1[9], p2[9], pl[9]; + int n; + double p1[9], p2[9]; char *geometry; if(!PyArg_ParseTuple(o, "sO!|i:draw_lines", @@ -2797,50 +2751,36 @@ static PyObject *pydraw_lines(PyObject * /*s*/, PyObject *o) { p2+0, p2+1, p2+2, p2+3, p2+4, p2+5, p2+6, p2+7, p2+8, - &dummy1, &dummy2, &dummy3)) { - if(!first) glEnd(); + &dummy1, &dummy2, &dummy3)) return NULL; - } - - // Suppress cppcheck false positive: - // 'first' == 1 when 'pl' is undefined and therefore not a problem. - // cppcheck-suppress uninitvar - if(first || memcmp(p1, pl, sizeof(p1)) - || (for_selection && n != nl)) { - if(!first) glEnd(); - if(for_selection && n != nl) { - glLoadName(n); - nl = n; - } - glBegin(GL_LINE_STRIP); - glvertex9(p1, geometry); - first = 0; - } - line9(p1, p2, geometry); - memcpy(pl, p2, sizeof(p1)); } - if(!first) glEnd(); + if(!warned) { + warned = true; + if(PyErr_WarnEx(PyExc_DeprecationWarning, + "linuxcnc.draw_lines() no longer draws; use rs274.glcanon_scene", + 1) < 0) + return NULL; + } Py_INCREF(Py_None); return Py_None; } -// Deprecated: draws with immediate-mode OpenGL (glBegin/glVertex*/glEnd), -// invalid in the 3.3 core profile now used for preview rendering. +// Retired: this drew with immediate-mode OpenGL (glBegin/glVertex*/glEnd), +// invalid in the 3.3 core profile now used for preview rendering. The name, +// signature, argument checking, and return value are kept for out-of-tree +// callers; it no longer draws. Replacement: rs274.glcanon_scene. static PyObject *pydraw_dwells(PyObject * /*s*/, PyObject *o) { + static bool warned = false; PyListObject *li; int for_selection = 0, is_lathe = 0, i, n; double alpha; char *geometry; - double delta = 0.015625; if(!PyArg_ParseTuple(o, "sO!dii:draw_dwells", &geometry, &PyList_Type, &li, &alpha, &for_selection, &is_lathe)) return NULL; - if (for_selection == 0) - glBegin(GL_LINES); - for(i=0; iclear) { - LOCK(); - if(s->is_xyuv) { - if(s->changed) { - glVertexPointer(3, GL_FLOAT, - sizeof(struct logger_point)/2, &s->p->x); - glColorPointer(4, GL_UNSIGNED_BYTE, - sizeof(struct logger_point)/2, &s->p->c); - glEnableClientState(GL_COLOR_ARRAY); - glEnableClientState(GL_VERTEX_ARRAY); - s->changed = 0; - } - s->lpts = s->npts; - glDrawArrays(GL_LINES, 0, 2*s->npts); - } else { - if(s->changed) { - glVertexPointer(3, GL_FLOAT, - sizeof(struct logger_point), &s->p->x); - glColorPointer(4, GL_UNSIGNED_BYTE, - sizeof(struct logger_point), &s->p->c); - glEnableClientState(GL_COLOR_ARRAY); - glEnableClientState(GL_VERTEX_ARRAY); - s->changed = 0; - } - s->lpts = s->npts; - glDrawArrays(GL_LINE_STRIP, 0, s->npts); - } - UNLOCK(); +// Retired: this plotted the backplot through the fixed-function client-state +// vertex arrays (glVertexPointer/glColorPointer/glDrawArrays), which the 3.3 +// core profile does not have. The name and return value are kept for +// out-of-tree callers; it no longer draws. Replacement: points(), which hands +// the same buffer to the core renderer for VBO upload — and which advances +// lpts, so the tool marker still tracks the plotted line. +static PyObject* Logger_call(pyPositionLogger * /*s*/, PyObject * /*o*/) { + static bool warned = false; + if(!warned) { + warned = true; + if(PyErr_WarnEx(PyExc_DeprecationWarning, + "positionlogger.call() no longer draws; use positionlogger.points()", + 1) < 0) + return NULL; } Py_INCREF(Py_None); return Py_None; @@ -3343,9 +3231,9 @@ static PyTypeObject PositionLoggerType = { static PyMethodDef emc_methods[] = { #define METH(name, doc) { #name, (PyCFunction) py##name, METH_VARARGS, doc } -METH(draw_lines, "Draw a bunch of lines in the 'rs274.glcanon' format"), -METH(draw_dwells, "Draw a bunch of dwell positions in the 'rs274.glcanon' format"), -METH(line9, "Draw a single line in the 'rs274.glcanon' format; assumes glBegin(GL_LINES)"), +METH(draw_lines, "Retired: no longer draws, use rs274.glcanon_scene"), +METH(draw_dwells, "Retired: no longer draws, use rs274.glcanon_scene"), +METH(line9, "Retired: no longer draws, use rs274.glcanon_scene"), METH(vertex9, "Get the 3d location for a 9d point"), METH(gui_rot_offsets, "Set x,y,z offsets for A,B,C rotations"), METH(gui_respect_offsets, "Enable rotations about g5x,g92 offsets"), diff --git a/src/emc/usr_intf/axis/scripts/axis.py b/src/emc/usr_intf/axis/scripts/axis.py index 75604eb8986..8ccf6b1e73f 100755 --- a/src/emc/usr_intf/axis/scripts/axis.py +++ b/src/emc/usr_intf/axis/scripts/axis.py @@ -21,8 +21,6 @@ # import pdb import sys, os import string -from OpenGL.GL import * -from OpenGL.GLU import * BASE = os.path.abspath(os.path.join(os.path.dirname(sys.argv[0]), "..")) sys.path.insert(0, os.path.join(BASE, "lib", "python")) diff --git a/tests/glcanon/expected b/tests/glcanon/expected new file mode 100644 index 00000000000..9766475a418 --- /dev/null +++ b/tests/glcanon/expected @@ -0,0 +1 @@ +ok diff --git a/tests/glcanon/test.sh b/tests/glcanon/test.sh new file mode 100755 index 00000000000..b144c7d724c --- /dev/null +++ b/tests/glcanon/test.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Unit tests for the OpenGL 3.3 core / GLES 3.1 preview renderer shared by +# AXIS, the GTK screens (Gremlin/gmoccapy/gscreen/hal_gremlin) and QtVCP. +# +# Both are GL-free: they exercise the backplot palette and the explicit camera +# matrices as plain numpy, so they need neither a GPU nor an X display. +set -e + +python3 test_backplot_palette.py >&2 +python3 test_camera_matrices.py >&2 + +echo ok diff --git a/tests/glcanon/test_backplot_palette.py b/tests/glcanon/test_backplot_palette.py new file mode 100644 index 00000000000..6aeed86b300 --- /dev/null +++ b/tests/glcanon/test_backplot_palette.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""The live backplot's palette: where its entries come from, and that an index +once assigned is never reassigned. + +These are the properties a pixel comparison cannot see. A palette rebuilt or +renumbered between frames still renders a single frame perfectly; it goes wrong +only across frames, because the backplot re-uploads just the changed tail of +the ring buffer and every vertex left resident keeps the index it was written +with. So the append-only rule is asserted directly here rather than inferred +from a picture. + +GL-free; runs anywhere numpy is available: + python3 tests/glcanon/test_backplot_palette.py +""" +import os +import struct +import sys +import unittest + +import numpy as np + +# Loaded by path rather than as rs274.glcanon_bake: the rs274 package __init__ +# pulls in the compiled gcode extension, which this test does not need. +import importlib.util +_spec = importlib.util.spec_from_file_location( + "glcanon_bake", os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "..", "lib", "python", "rs274", + "glcanon_bake.py")) +bake = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(bake) + + +def point(x, y, z, c, c2=None): + """One ``struct logger_point``.""" + return struct.pack('<3f4B3f4B', x, y, z, *c, + x, y + 1.0, 1.5, *(c2 if c2 is not None else c)) + + +def cats_of(verts): + return verts[:, 3].view(np.uint32) >> bake.BACKPLOT_CAT_SHIFT + + +def linenos_of(verts): + return verts[:, 3].view(np.uint32) & ((1 << bake.BACKPLOT_CAT_SHIFT) - 1) + + +RED = (255, 0, 0, 255) +GREEN = (0, 255, 0, 255) +BLUE = (0, 0, 255, 255) + + +class Layout(unittest.TestCase): + def test_indexed_layout_is_the_shared_twenty_byte_vertex(self): + raw = point(1.0, 2.0, 3.0, RED) + point(4.0, 5.0, 6.0, RED) + pal = bake.ColorPalette() + v = bake.backplot_vertices(raw, 2, 0, palette=pal) + self.assertEqual(v.shape, (2, bake.TRAJ_FLOATS_PER_VERTEX)) + self.assertEqual(v.dtype, np.float32) + self.assertEqual(v.nbytes // 2, 16) + np.testing.assert_allclose(v[:, 0:3], [[1, 2, 3], [4, 5, 6]]) + # The line-number bits are unused: the trail is never picked. + np.testing.assert_array_equal(linenos_of(v), [0, 0]) + + def test_without_a_palette_the_old_layout_is_returned(self): + raw = point(1.0, 2.0, 3.0, RED) + v = bake.backplot_vertices(raw, 1, 0) + self.assertEqual(v.shape, (1, bake.FLOATS_PER_VERTEX)) + + def test_empty_buffer_matches_the_requested_layout(self): + self.assertEqual(bake.backplot_vertices(b"", 0, 0).shape, + (0, bake.FLOATS_PER_VERTEX)) + self.assertEqual( + bake.backplot_vertices(b"", 0, 0, + palette=bake.ColorPalette()).shape, + (0, bake.TRAJ_FLOATS_PER_VERTEX)) + + +class PaletteSource(unittest.TestCase): + def test_entries_come_from_the_stored_bytes(self): + """Not from the preview's colour table, which holds untruncated floats. + + axis.py stores ``int(component * 255)``. For backplottraverse's 0.30 + that is 76, and 76/255 is 0.298039 - so a palette built from the table + would shift every backplot colour by up to 1/255. Invisible in a + screenshot threshold, wrong nonetheless. + """ + stored = (int(0.30 * 255), int(0.50 * 255), int(0.50 * 255), + int(0.25 * 255)) + self.assertEqual(stored, (76, 127, 127, 63)) + pal = bake.ColorPalette() + bake.backplot_vertices(point(0, 0, 0, stored), 1, 0, palette=pal) + entry = pal.entries[0] + np.testing.assert_allclose(entry, [c / 255.0 for c in stored]) + self.assertNotEqual(entry[0], 0.30) + self.assertNotEqual(entry[1], 0.50) + + def test_entry_matches_what_the_old_bake_put_on_each_vertex(self): + """The palette entry is exactly the colour the old path drew.""" + c = (76, 127, 127, 63) + raw = point(0, 0, 0, c) + point(1, 1, 1, c) + old = bake.backplot_vertices(raw, 2, 0) # per-vertex colours + pal = bake.ColorPalette() + bake.backplot_vertices(raw, 2, 0, palette=pal) + np.testing.assert_array_equal( + np.asarray(pal.entries[0], dtype=np.float32), old[0, 3:7]) + + def test_first_seen_order(self): + raw = point(0, 0, 0, GREEN) + point(1, 0, 0, RED) + point(2, 0, 0, BLUE) + pal = bake.ColorPalette() + v = bake.backplot_vertices(raw, 3, 0, palette=pal) + np.testing.assert_array_equal(cats_of(v), [0, 1, 2]) + np.testing.assert_allclose(pal.entries, + [(0, 1, 0, 1), (1, 0, 0, 1), (0, 0, 1, 1)]) + + +class AppendOnly(unittest.TestCase): + """The one failure mode that yields a wrong picture from correct-looking + code: renumbering a colour that resident vertices still refer to.""" + + def test_a_new_colour_leaves_earlier_points_untouched(self): + pal = bake.ColorPalette() + first = point(0, 0, 0, RED) + point(1, 0, 0, RED) + before = bake.backplot_vertices(first, 2, 0, palette=pal) + entries_before = list(pal.entries) + + # A later frame appends points of a colour never seen before. + second = first + point(2, 0, 0, GREEN) + point(3, 0, 0, GREEN) + after = bake.backplot_vertices(second, 4, 0, palette=pal) + + np.testing.assert_array_equal(cats_of(after)[:2], cats_of(before)) + self.assertEqual(pal.entries[:len(entries_before)], entries_before) + self.assertEqual(pal.entries[cats_of(after)[2]], (0.0, 1.0, 0.0, 1.0)) + + def test_a_colour_that_stops_occurring_keeps_its_index(self): + pal = bake.ColorPalette() + bake.backplot_vertices(point(0, 0, 0, RED), 1, 0, palette=pal) + # The ring has dropped every red point; only green remains. + v = bake.backplot_vertices(point(0, 0, 0, GREEN), 1, 0, palette=pal) + self.assertEqual(pal.entries[0], (1.0, 0.0, 0.0, 1.0)) + self.assertEqual(int(cats_of(v)[0]), 1) + + def test_indices_are_stable_across_a_growing_prefix(self): + """Converting a longer prefix must not renumber the shorter one.""" + pal = bake.ColorPalette() + colours = [RED, GREEN, BLUE, RED, GREEN] + raw = b"".join(point(i, 0, 0, c) for i, c in enumerate(colours)) + seen = None + for n in range(1, len(colours) + 1): + v = bake.backplot_vertices(raw, n, 0, palette=pal) + if seen is not None: + np.testing.assert_array_equal(cats_of(v)[:len(seen)], seen) + seen = cats_of(v) + + +class IncrementalConversion(unittest.TestCase): + """A trail converted a tail at a time is the trail converted in one pass. + + This is what narrowing the per-frame conversion rests on, and it is not + self-evident: indices are assigned in first-seen order *within the + converted array*, and a tail sees a different slice than the whole buffer + does. It holds only because the palette is append-only and already carries + every colour the earlier frames saw - so it is asserted rather than + reasoned about. + """ + + COLOURS = [RED, GREEN, BLUE, (255, 255, 0, 191), (0, 255, 255, 63)] + + #: the npts the logger reports on successive frames + FRAMES = (2, 3, 6, 7, 18, 34, 40) + + def buffer(self, npts): + return b"".join( + point(i * 0.5, 0, 0, self.COLOURS[(i // 3) % len(self.COLOURS)]) + for i in range(npts)) + + def check(self, is_xyuv): + npts = self.FRAMES[-1] + raw = self.buffer(npts) + vpp = 2 if is_xyuv else 1 + + whole_pal = bake.ColorPalette() + whole = bake.backplot_vertices(raw, npts, is_xyuv, palette=whole_pal) + + # The caller's rule: start at the last point it already sent, which + # the C may still be moving. + tail_pal = bake.ColorPalette() + built = np.zeros_like(whole) + prev = 0 + for n in self.FRAMES: + first_point = max(prev - 1, 0) + v = bake.backplot_vertices(raw, n, is_xyuv, + first_point=first_point, + palette=tail_pal) + self.assertEqual(len(v), (n - first_point) * vpp) + built[first_point * vpp:first_point * vpp + len(v)] = v + prev = n + + np.testing.assert_array_equal(cats_of(built), cats_of(whole)) + np.testing.assert_array_equal(built, whole) + self.assertEqual(tail_pal.entries, whole_pal.entries) + + def test_a_tail_at_a_time_matches_one_pass(self): + self.check(0) + + def test_a_tail_at_a_time_matches_one_pass_in_foam(self): + self.check(1) + + +class Foam(unittest.TestCase): + def test_both_plane_vertices_share_one_entry(self): + """The C writes ``np.c = np.c2 = c``, so the pair is one colour.""" + raw = point(0, 0, 0, RED) + point(1, 0, 0, GREEN) + pal = bake.ColorPalette() + v = bake.backplot_vertices(raw, 2, 1, palette=pal) + self.assertEqual(v.shape, (4, bake.TRAJ_FLOATS_PER_VERTEX)) + cats = cats_of(v) + self.assertEqual(int(cats[0]), int(cats[1])) # point 0, both planes + self.assertEqual(int(cats[2]), int(cats[3])) # point 1, both planes + self.assertNotEqual(int(cats[0]), int(cats[2])) + self.assertEqual(len(pal.entries), 2) + + def test_plane_positions_are_interleaved_as_before(self): + pal = bake.ColorPalette() + v = bake.backplot_vertices(point(1, 2, 3, RED), 1, 1, palette=pal) + np.testing.assert_allclose(v[0, 0:3], [1, 2, 3]) # XY plane + np.testing.assert_allclose(v[1, 0:3], [1, 3, 1.5]) # UV plane + + +class Overflow(unittest.TestCase): + def test_too_many_colours_falls_back_rather_than_wrapping(self): + colours = [(i * 8, 0, 0, 255) for i in range(bake.PALETTE_SIZE + 2)] + raw = b"".join(point(i, 0, 0, c) for i, c in enumerate(colours)) + pal = bake.ColorPalette() + v = bake.backplot_vertices(raw, len(colours), 0, palette=pal) + self.assertTrue(pal.overflowed) + self.assertEqual(v.shape, (len(colours), bake.FLOATS_PER_VERTEX)) + # Every colour still exact - none dropped, merged or wrapped. + np.testing.assert_allclose(v[:, 3], [c[0] / 255.0 for c in colours]) + + def test_the_bound_is_not_reached_by_the_logger(self): + """Six motion types into eight slots; the fallback cannot trigger.""" + self.assertLessEqual(6, bake.PALETTE_SIZE) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/glcanon/test_camera_matrices.py b/tests/glcanon/test_camera_matrices.py new file mode 100644 index 00000000000..6c028d4c527 --- /dev/null +++ b/tests/glcanon/test_camera_matrices.py @@ -0,0 +1,144 @@ +"""Camera matrices recorded from the pre-refactor GL compatibility path. + +References were captured with Mesa llvmpipe's OpenGL 4.5 compatibility context +using an 800x600 viewport, center (1.25, -2.5, 3.75), and extents (4, 6, 8). +OpenGL returns column-major matrices, hence the transpose before comparison. +""" + +import os +import sys +import unittest + +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "..", "lib", "python")) + +import glnav + + +class Navigation(glnav.GlNavBase): + def _redraw(self): + pass + + def winfo_width(self): + return 800 + + def winfo_height(self): + return 600 + + def extents_info(self): + return (1.25, -2.5, 3.75), (4.0, 6.0, 8.0) + + def is_lathe(self): + return self.lathe + + +def legacy_matrix(columns): + return np.asarray(columns, dtype=np.float64).T + + +REFERENCES = { + "rotate_snap": [[1, 0, 0, 0], [0, -4.3711388e-08, 1, 0], + [0, -1, -4.3711388e-08, 0], [0, 1.25, 6.25, 1]], + "translate_zoom_boost": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], + [0.1273418665, 0.0955063999, 0, 1]], + "view_x": [[-4.3711388e-08, 0, 1, 0], [1, -4.3711388e-08, 4.3711388e-08, 0], + [4.3711388e-08, 1, 1.9106855e-15, 0], [2.49999976, -3.75, -1.24999988, 1]], + "view_y": [[-4.3711388e-08, -1, 4.3711388e-08, 0], [0, -4.3711388e-08, -1, 0], + [1, -4.3711388e-08, 1.9106855e-15, 0], [-3.75, 1.25, -2.5, 1]], + "view_y2": [[-4.3711388e-08, 1, 4.3711388e-08, 0], [0, -4.3711388e-08, 1, 0], + [1, 4.3711388e-08, 1.9106855e-15, 0], [-3.75, -1.25000024, 2.5, 1]], + "view_z": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [-1.25, 2.5, -3.75, 1]], + "view_z2": [[-4.3711388e-08, -1, 0, 0], [1, -4.3711388e-08, 0, 0], + [0, 0, 1, 0], [2.5, 1.24999988, -3.75, 1]], + "view_p": [[.9063076973, -.2113092095, .3659983277, 0], + [.4226184487, .4531538188, -.7848855257, 0], + [0, .8660254478, .4999999702, 0], + [-.0763385296, -1.850574255, -4.29471159, 1]], +} + + +class ExplicitCameraMatrixTest(unittest.TestCase): + def nav(self, lathe=False): + result = Navigation() + result.lathe = lathe + return result + + def assert_matrix(self, name, nav): + np.testing.assert_allclose(nav.get_modelview_matrix(), legacy_matrix(REFERENCES[name]), + rtol=0, atol=3e-6) + + def test_rotation_snaps_to_ninety_degrees(self): + nav = self.nav() + nav.set_centerpoint(1.25, -2.5, 3.75) + nav.lat = 89 + nav.recordMouse(0, 0) + nav.rotate(4, 2) # 90° and 2° both snap to legacy 90°/0° angles. + self.assert_matrix("rotate_snap", nav) + + def test_translate_uses_projection_and_zoom_boost(self): + nav = self.nav() + nav.distance = 2.0 + nav.recordMouse(10, 20) + nav.translate(30, 5) + self.assert_matrix("translate_zoom_boost", nav) + + def test_zoom_changes_distance_but_not_modelview(self): + nav = self.nav() + original = nav.get_modelview_matrix() + nav.zoomin() + nav.zoomout() + np.testing.assert_allclose(nav.get_modelview_matrix(), original) + self.assertAlmostEqual(nav.distance, 9.98) + + def test_view_presets_match_legacy(self): + for name, lathe in (("x", False), ("y", True), ("y2", False), + ("z", False), ("z2", False), ("p", False)): + with self.subTest(view=name): + nav = self.nav(lathe) + getattr(nav, "set_view_" + name)() + self.assert_matrix("view_" + name, nav) + + def test_translate_modelview_matches_manual_composition(self): + nav = self.nav() + nav.set_view_p() + before = nav.get_modelview_matrix() + nav.translate_modelview(3.5, -1.75, 0.25) + expected = glnav.multiply(before, glnav.translation_matrix(3.5, -1.75, 0.25)) + np.testing.assert_allclose(nav.get_modelview_matrix(), expected) + + def test_translate_modelview_offset_survives_plasmac_sequence(self): + # The plasmac table view: top view, offset to the machine table centre, + # then refit the eyepoint. The offset has to survive the refit. + xcenter, ycenter = 12.0, -7.5 + nav = self.nav() + nav.set_view_z() + after_preset = nav.get_modelview_matrix() + nav.translate_modelview(-xcenter, -ycenter, 0) + nav.set_eyepoint_from_extents(4.0, 6.0) + expected = glnav.multiply(after_preset, + glnav.translation_matrix(-xcenter, -ycenter, 0)) + np.testing.assert_allclose(nav.get_modelview_matrix(), expected) + + def test_translate_modelview_leaves_navigation_state_alone(self): + nav = self.nav() + nav.set_view_p() + nav.recordMouse(10, 20) + nav.translate(30, 5) + state = (nav.lat, nav.lon, nav.distance, nav._totalx, nav._totaly) + nav.translate_modelview(-4.0, 2.5, 0) + self.assertEqual((nav.lat, nav.lon, nav.distance, nav._totalx, nav._totaly), state) + + def test_ortho_projection_matches_legacy_scale(self): + nav = self.nav() + nav.distance = 2.0 + k = 2.0 ** .55555 + expected = glnav.multiply( + glnav.ortho_matrix(-k, k, -k * 600 / 800, k * 600 / 800, -1000, 1000), + glnav.translation_matrix(0, 0, -1)) + np.testing.assert_allclose(nav.get_projection_matrix(800, 600), expected) + + +if __name__ == "__main__": + unittest.main() From 00bedfde939d0f8da430cc1c007399b836493277 Mon Sep 17 00:00:00 2001 From: Alexey Presnyakov <309782758+alex-pres@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:33:11 +0400 Subject: [PATCH 8/8] gcode: require use_move_batches to be True, not merely truthy A canon that answers unknown attributes with a stub - the catch-all __getattr__ idiom, as tests/interp_initcode's canon uses - handed back a callable for both use_move_batches and move_batch and was opted into the batch protocol without asking, silently dropping its moves into the stub. --- lib/python/rs274/glcanon_batch.py | 6 ++++-- src/emc/rs274ngc/gcodemodule.cc | 17 +++++++++++++---- tests/interp/python/error/canon.py | 4 ++++ tests/interp_initcode/test-ui.py | 5 +++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/python/rs274/glcanon_batch.py b/lib/python/rs274/glcanon_batch.py index 3f846de5c64..e8f1d7a3edb 100644 --- a/lib/python/rs274/glcanon_batch.py +++ b/lib/python/rs274/glcanon_batch.py @@ -16,8 +16,9 @@ """The reference consumer of ``gcode.parse``'s move-batch protocol. The protocol is opt-in and lives in ``src/emc/rs274ngc/gcodemodule.cc`` (class -``MoveBatch``): a canon that sets ``use_move_batches`` and provides -``move_batch`` stops receiving ``straight_feed``/``straight_traverse``/ +``MoveBatch``): a canon that sets ``use_move_batches = True`` (the bool itself - +anything else, including a merely truthy value, stays on the legacy protocol) +and provides a callable ``move_batch`` stops receiving ``straight_feed``/``straight_traverse``/ ``straight_probe``/``rigid_tap``/``dwell``/``user_defined_function``/ ``change_tool``/``tool_offset`` as one Python call per event, and receives them instead as blocks of fixed-width float64 rows. On a million-move file that is @@ -115,6 +116,7 @@ class MoveBatchMixin: """ #: What the C side reads to choose the protocol, once, at parse start. + #: Must be the bool ``True``; the C side ignores any other value. use_move_batches = True def batch_progress(self, lineno): diff --git a/src/emc/rs274ngc/gcodemodule.cc b/src/emc/rs274ngc/gcodemodule.cc index 06e7c8c7c22..d6b66a53ce3 100644 --- a/src/emc/rs274ngc/gcodemodule.cc +++ b/src/emc/rs274ngc/gcodemodule.cc @@ -202,12 +202,19 @@ static InterpBase *pinterp; // // An *opt-in* alternative to the per-event callback protocol, for consumers // that would rather receive a million moves as a few numpy-shaped blocks than -// as a million Python calls. A canon object opts in by setting a truthy -// `use_move_batches` attribute and providing a callable `move_batch`; both are +// as a million Python calls. A canon object opts in by setting +// `use_move_batches = True` and providing a callable `move_batch`; both are // read once, in parse_file, before any interpretation. Everything below is // inert when the flag is absent, and the legacy callback sequence is then // byte-for-byte what it always was. // +// The flag must be a *bool*, not merely truthy: a canon that answers every +// unknown attribute with a stub - `def __getattr__(self, name): return lambda +// *a: None`, a common idiom for partial canons, and what +// tests/interp_initcode's does - would otherwise hand back a callable for both +// `use_move_batches` and `move_batch` and be opted in without ever asking, +// silently dropping every batched move into the stub. +// // In batch mode the canon functions listed under `MoveBatch::Kind` do not call // Python at all. Each appends one fixed-width row of 13 float64s to a C-owned // buffer: @@ -307,9 +314,11 @@ class MoveBatch { PyErr_Clear(); // no attribute: the legacy protocol return true; } - int opted_in = PyObject_IsTrue(flag); + // Anything that is not a bool is not an opt-in - see the note on + // catch-all `__getattr__` above. Legacy protocol, no complaint: a + // canon that never mentions the flag must not be made to fail. + bool opted_in = PyBool_Check(flag) && flag == Py_True; Py_DECREF(flag); - if(opted_in < 0) return false; if(!opted_in) return true; // Fail fast rather than fall back: a canon that asked for batches and // silently got per-move callbacks would look like it worked and quietly diff --git a/tests/interp/python/error/canon.py b/tests/interp/python/error/canon.py index c6ae0d26581..276679c49ed 100644 --- a/tests/interp/python/error/canon.py +++ b/tests/interp/python/error/canon.py @@ -8,6 +8,10 @@ def float_fmt(f): return "%5s" % f class Canon: + # Stay on the per-event canon protocol; the catch-all __getattr__ below + # would otherwise answer gcode.parse's move-batch probe with a callable. + use_move_batches = False + def __getattr__(self, attr): """Assume that any unknown attribute is a canon call; just print its args and return None""" diff --git a/tests/interp_initcode/test-ui.py b/tests/interp_initcode/test-ui.py index b463ee0ad6b..9ab3588dbc2 100755 --- a/tests/interp_initcode/test-ui.py +++ b/tests/interp_initcode/test-ui.py @@ -10,6 +10,11 @@ class PreviewCanon(PrintCanon, StatMixin): + # Stay on the per-event canon protocol. Named explicitly because the + # catch-all __getattr__ below would otherwise answer gcode.parse's probe + # with a callable and opt this canon into the move-batch protocol. + use_move_batches = False + def __init__(self, stat, randomtc, parameter): StatMixin.__init__(self, stat, randomtc) self.parameter_file = parameter