From cfb284dbd5f10a2f982e71172c9141f285673bc3 Mon Sep 17 00:00:00 2001 From: Alexey Presnyakov <309782758+alex-pres@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:59:28 +0400 Subject: [PATCH] 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 | 9 + .../getting-started/updating-linuxcnc.adoc | 128 +- lib/python/glnav.py | 251 +- lib/python/hershey.py | 100 +- 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 +-- tests/glcanon/expected | 1 + tests/glcanon/test.sh | 12 + tests/glcanon/test_backplot_palette.py | 248 ++ tests/glcanon/test_camera_matrices.py | 114 + 18 files changed, 7240 insertions(+), 1696 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 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/docs/src/config/python-interface.adoc b/docs/src/config/python-interface.adoc index 219a873d244..58b28edefe3 100644 --- a/docs/src/config/python-interface.adoc +++ b/docs/src/config/python-interface.adoc @@ -935,6 +935,15 @@ 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, which converts + these points into its own narrower GPU vertex format before upload; see + `rs274.glcanon_bake.LOGGER_DTYPE` for the matching numpy dtype. + `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 d367d5919b0..f3a99ac7144 100644 --- a/lib/python/glnav.py +++ b/lib/python/glnav.py @@ -1,95 +1,23 @@ 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): - 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) + """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 pango_font_pre(rgba=(1., 1., 0., 1.)): @@ -100,15 +28,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 +115,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 +183,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 +201,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 +258,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 +293,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) self._redraw() @@ -314,13 +301,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 ) ) @@ -390,12 +378,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 @@ -404,11 +407,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 @@ -418,10 +421,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 @@ -431,7 +433,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 @@ -439,9 +441,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 @@ -452,7 +454,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() @@ -466,4 +468,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..7dbb8fe300d 100644 --- a/lib/python/hershey.py +++ b/lib/python/hershey.py @@ -15,10 +15,6 @@ # along with this program; if not, write to the Free Software # 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} @@ -104,60 +100,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 +167,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 ############# 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..f3a63fb59fc --- /dev/null +++ b/tests/glcanon/test.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Unit tests for the OpenGL 3.3 core 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..4172e5c210f --- /dev/null +++ b/tests/glcanon/test_backplot_palette.py @@ -0,0 +1,248 @@ +#!/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, 20) + np.testing.assert_allclose(v[:, 0:3], [[1, 2, 3], [4, 5, 6]]) + # Line number and distance are unused: the trail is neither picked + # nor dashed. + np.testing.assert_array_equal(linenos_of(v), [0, 0]) + np.testing.assert_array_equal(v[:, 4], [0.0, 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..4364f529505 --- /dev/null +++ b/tests/glcanon/test_camera_matrices.py @@ -0,0 +1,114 @@ +"""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_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()