2113 lines
88 KiB
Python
2113 lines
88 KiB
Python
# This is a component of AXIS, a front-end for emc
|
|
# Copyright 2004, 2005, 2006 Jeff Epler <jepler@unpythonic.net>
|
|
# 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 logging
|
|
import math
|
|
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
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
#: 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_DEBUG=1 logs the drawn/gated-out part split each time it changes.
|
|
#: The same flag the renderer's glGetError check reads - one switch for the
|
|
#: whole preview - so it is taken from there rather than read again here.
|
|
SCENE_DEBUG = glcanon_gl.GL_DEBUG
|
|
|
|
#: OpenGL's default ``GL_LIGHT_MODEL_AMBIENT`` - the scene-wide ambient every
|
|
#: lit surface receives on top of any light's own ambient. The fixed-function
|
|
#: tool marker this renderer replaced never set it, so it took the default,
|
|
#: and its material ambient is ``(1,1,1)`` - which makes this a flat addition
|
|
#: to ``tool_ambient``. Not a colour-table entry, because it was never one:
|
|
#: it is a property of the pipeline being reproduced. See
|
|
#: :meth:`Primitives.draw_cone`.
|
|
LIGHT_MODEL_AMBIENT = 0.2
|
|
|
|
# View ports coordinates
|
|
VX = 0
|
|
VY = 1
|
|
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', 'caps', '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
|
|
#: What the live GL context can do, where OpenGL 3.3 core and OpenGL ES 3.1
|
|
#: differ - the API, and the widest line the driver grants. Read once
|
|
#: from the context at the top of the frame and passed
|
|
#: down: a part that wants to know must read this, and MUST NOT call
|
|
#: ``glGetString``, query an extension, or probe a limit itself. That is
|
|
#: the same rule as the visibility gate - a part does not decide, and does
|
|
#: not discover, the conditions it draws under.
|
|
caps: glcanon_gl.GLCaps
|
|
#: Colour table, ``rs274.glcanon.GlCanonDraw.colors`` resolved for this
|
|
#: host. Values are either an rgb 3-tuple of floats in 0..1 or, for the
|
|
#: ``*_alpha`` keys, a bare float - hence ``Any`` rather than a union that
|
|
#: 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,8) 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).
|
|
|
|
The ambient term is ``LIGHT_MODEL_AMBIENT + tool_ambient``, not
|
|
``tool_ambient`` alone: the fixed-function pipeline this replaces lit
|
|
the marker with *two* ambient contributions, the light's own
|
|
(``glLightfv(GL_LIGHT0, GL_AMBIENT, tool_ambient)``) and the global
|
|
light model's, and the material ambient it multiplies is ``(1,1,1)``.
|
|
Dropping the global one is a visible 0.2 off the marker's darkest
|
|
faces - 153 to 102 on the default colours.
|
|
|
|
Saturation is left to the framebuffer write, as it is in fixed
|
|
function: with the default colours the lit faces reach 1.2 and clamp
|
|
there. An explicit clamp here would be a second one.
|
|
"""
|
|
mv = ctx.mv.top()
|
|
ambient = tuple(c + LIGHT_MODEL_AMBIENT
|
|
for c in ctx.colors['tool_ambient'])
|
|
ctx.renderer.draw_cone(
|
|
ctx.mv.mvp(), mv[:3, :3], tuple(color) + (1.0,),
|
|
mesh_verts=self.cone_mesh() if mesh_verts is None else mesh_verts,
|
|
light_dir=(1.0, -1.0, 1.0),
|
|
ambient=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:
|
|
"""Append a part. The right operation for one with no ordering
|
|
constraint; the edits below are for one that has."""
|
|
self.parts.append((part, gate))
|
|
return part
|
|
|
|
# -- editing -----------------------------------------------------------
|
|
#
|
|
# ``build_scene()`` is documented as the hook for "a different set or order
|
|
# of parts", so these are what a host takes it with. All of them are
|
|
# position-preserving by construction, because the order is load-bearing -
|
|
# the grid draws first with depth writes off, the highlight is a second
|
|
# draw of the program's own buffers and must immediately follow it, the
|
|
# overlay draws last in screen space. An edit written by hand as
|
|
# remove-then-add leaves the part in the scene and breaks all three, and
|
|
# the frame still renders, so nothing reports it.
|
|
|
|
def index_of(self, part: Part) -> int:
|
|
"""Where ``part`` sits in the draw order.
|
|
|
|
By identity, not by class: two instances of one part class are two
|
|
parts, and a composition may hold both. Absent is an error rather than
|
|
a sentinel - every edit below routes through here, and an edit that
|
|
quietly did nothing would leave a host believing its override took.
|
|
"""
|
|
for i, (candidate, _gate) in enumerate(self.parts):
|
|
if candidate is part:
|
|
return i
|
|
raise ValueError("%s is not in this scene" % type(part).__name__)
|
|
|
|
def regate(self, part: Part, gate: Gate) -> Part:
|
|
"""Change when a part is drawn, keeping it and its position.
|
|
|
|
``regate(part, lambda ctx: False)`` is how a host disables a part it
|
|
must not remove - one still reached by name from ``GlCanonDraw``.
|
|
"""
|
|
self.parts[self.index_of(part)] = (part, gate)
|
|
return part
|
|
|
|
def replace(self, old: Part, new: Part, gate: Gate | None = None) -> Part:
|
|
"""Swap ``old`` for ``new`` in place, inheriting ``old``'s gate unless
|
|
one is given. Returns ``new``, so the attribute and the list position
|
|
are updated in one statement::
|
|
|
|
self.grid = self.replace(self.grid, MyGrid())
|
|
|
|
Doing only one of the two is the trap this returns for: the scene draws
|
|
one part while ``GlCanonDraw``'s ``draw_grid``/``show_extents``/... call
|
|
the other, both every frame.
|
|
"""
|
|
i = self.index_of(old)
|
|
self.parts[i] = (new, self.parts[i][1] if gate is None else gate)
|
|
return new
|
|
|
|
def remove(self, part: Part) -> None:
|
|
"""Drop a part entirely. For one reached by name from outside the scene,
|
|
prefer :meth:`regate` - see :class:`PreviewScene`."""
|
|
del self.parts[self.index_of(part)]
|
|
|
|
def insert_before(self, anchor: Part, part: Part,
|
|
gate: Gate = lambda ctx: True) -> Part:
|
|
"""Insert ``part`` immediately before ``anchor``, and return it."""
|
|
self.parts.insert(self.index_of(anchor), (part, gate))
|
|
return part
|
|
|
|
def insert_after(self, anchor: Part, part: Part,
|
|
gate: Gate = lambda ctx: True) -> Part:
|
|
"""Insert ``part`` immediately after ``anchor``, and return it."""
|
|
self.parts.insert(self.index_of(anchor) + 1, (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 is ON here because the parts that composite outnumber the
|
|
ones that must not: the live backplot's colours carry their own alpha,
|
|
and geometry with alpha 1 is unaffected either way. It is NOT a
|
|
statement that everything drawn is meant to composite. The program is
|
|
the case that is not - its baked per-category alpha reaches the
|
|
framebuffer only when the alpha toggle says so - and it turns blending
|
|
off in its own scope, which is where a part's non-baseline state
|
|
belongs. Choosing the baseline to suit one part instead would make
|
|
that part's requirement invisible at the point it applies.
|
|
"""
|
|
glEnable(GL_DEPTH_TEST)
|
|
glDepthFunc(GL_LESS)
|
|
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_DEBUG=1: report the gated-in set, so "a hidden part is
|
|
never entered" is checkable at runtime.
|
|
|
|
Emitted when the set changes, not once per frame - the debug level is
|
|
not a substitute for that, since the composition is the same on most
|
|
consecutive frames and a per-frame record would bury the change that
|
|
matters.
|
|
"""
|
|
state = (tuple(drawn), tuple(gated_out))
|
|
if state == self._logged:
|
|
return
|
|
self._logged = state
|
|
log.debug("glcanon scene: drew %s | gated out %s",
|
|
", ".join(drawn) or "-", ", ".join(gated_out) or "-")
|
|
|
|
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]:
|
|
"""The toggle's compositing, in *both* of its states.
|
|
|
|
The baked colours carry a per-category alpha (traverse 1/3, feed 1/3, arcs
|
|
1/2), and whether that alpha reaches the framebuffer is the toggle's to
|
|
decide - exactly as it was when the colour was a ``glColor4f`` and the
|
|
toggle was the ``glEnable(GL_BLEND)`` around the display lists. So:
|
|
|
|
========== ============================= ====================
|
|
toggle depth blend
|
|
========== ============================= ====================
|
|
on off, so every line composites baseline (on)
|
|
off baseline (on) off, alpha discarded
|
|
========== ============================= ====================
|
|
|
|
Blend-off is what makes the default view opaque. Without it the alpha is
|
|
applied unconditionally and white feed lines land at 1/3 grey - and, on a
|
|
software rasteriser, every fragment becomes a read-modify-write of the
|
|
colour buffer that no depth rejection can skip.
|
|
|
|
Each branch restores what it changed and nothing else, so the frame's
|
|
baseline is in effect again for the parts drawn after the program - the
|
|
live backplot among them, whose own alpha is not this toggle's business.
|
|
|
|
A property of how the program sits in the rest of the scene, not of its own
|
|
geometry - which is why both parts drawn from the program's buffers need
|
|
it. Shared as a context manager the scopes compose with ``with``, rather
|
|
than as a base class: composition, not inheritance.
|
|
"""
|
|
if ctx.program_alpha:
|
|
glDisable(GL_DEPTH_TEST)
|
|
try:
|
|
yield
|
|
finally:
|
|
glEnable(GL_DEPTH_TEST)
|
|
else:
|
|
glDisable(GL_BLEND)
|
|
try:
|
|
yield
|
|
finally:
|
|
glEnable(GL_BLEND)
|
|
|
|
|
|
class ProgramPart(Part):
|
|
"""The program's trajectory: traverse, feed and arc moves.
|
|
|
|
Rapids draw solid, as they do in the pre-change renderer: nothing in this
|
|
renderer dashes, and there is no attribute or uniform left to do it with
|
|
(see ``glcanon_bake.program_parts``).
|
|
"""
|
|
|
|
def __init__(self, resource: ProgramResource | None = None) -> None:
|
|
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)
|
|
|
|
|
|
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()
|
|
|
|
#: Width over a program the driver drew at the same width. The tie-break
|
|
#: below plus three pixels against one is what makes the highlight read.
|
|
WIDTH = 3.0
|
|
#: Width over a program the driver drew as hairlines. Wider because there
|
|
#: is less to win against - a 1px highlight over a 1px program differs
|
|
#: only in colour, and at a shallow angle on a busy program that is not
|
|
#: enough to find the selected line by eye.
|
|
HAIRLINE_WIDTH = 4.0
|
|
|
|
@contextmanager
|
|
def scope(self, ctx: FrameContext) -> Iterator[None]:
|
|
"""The program's alpha compositing, plus the depth tie-break and the
|
|
width that make the highlight sit over the geometry it duplicates.
|
|
|
|
The width is the one place in the scene where the driver's own limit
|
|
changes what a part asks for. Where ``glLineWidth`` is capped at 1 -
|
|
a forward-compatible core profile, or GLES on the Raspberry Pi's v3d -
|
|
the program body is drawn as hairlines and is not quad-expanded (10M
|
|
vertices is too many), so the highlight asks for more and gets it: it
|
|
*is* quad-expanded, being a handful of segments. Read from the frame's
|
|
capability record; a part never asks GL this itself.
|
|
"""
|
|
with super().scope(ctx), program_alpha(ctx):
|
|
set_line_width(self.WIDTH if ctx.caps.max_line_width >= self.WIDTH
|
|
else self.HAIRLINE_WIDTH)
|
|
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.
|
|
|
|
Sets its own blend constant, as the two paths in :meth:`draw_solid`
|
|
do and as the legacy ``make_cone`` display list did. Without it the
|
|
constant is whatever GL was last left holding - 0 on a fresh context,
|
|
but ``tool_alpha`` or ``lathetool_alpha`` once a large-diameter or
|
|
lathe tool has been drawn - so the marker's pixels would depend on
|
|
which tool the session happened to load first. Under the caller's
|
|
GL_ONE/GL_CONSTANT_ALPHA blend this is what lets the geometry behind
|
|
the marker show through it.
|
|
"""
|
|
glBlendColor(0, 0, 0, ctx.colors['tool_alpha'])
|
|
if ctx.canon and not ctx.disable_cone_scaling:
|
|
g = ctx.canon
|
|
|
|
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.
|
|
|
|
**What a subclass must keep.** Some of these parts are reached by name from
|
|
outside the scene, so an override that drops one breaks a path that has
|
|
nothing to do with drawing order:
|
|
|
|
================ =======================================================
|
|
attribute reached by
|
|
================ =======================================================
|
|
``program`` ``GlCanonDraw.__init__`` binds the ``Picker`` to
|
|
``scene.program.resource``, and program staleness and
|
|
upload go through it; a replacement must expose the
|
|
*same* ``ProgramResource``, or what is picked stops
|
|
matching what is drawn - silently, since neither errors
|
|
``highlight`` holds that same resource, and must stay immediately
|
|
after ``program``: it is a second draw of those buffers
|
|
``grid`` ``draw_grid``, ``draw_grid_permuted``
|
|
``extents`` ``show_extents``
|
|
``bounding_box`` ``draw_bounding_box``
|
|
``overlay`` ``show_overlay``, ``show_icon_init``, ``show_icon``
|
|
================ =======================================================
|
|
|
|
This is a declaration, not a check: a scene built without a part whose host
|
|
path that GUI never triggers works, and rejecting it at construction would
|
|
break a working override for no defect. To hide one of these rather than
|
|
lose it, ``regate`` it - it keeps both its position and its name.
|
|
"""
|
|
|
|
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
|