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

1589 lines
72 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>
# 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 16-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 logging
import math
from typing import Any, Optional, Sequence
import numpy as np
import numpy.typing as npt
log = logging.getLogger(__name__)
# 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) -> 8 float32.
# ``WideVerts`` is the type saying so; rs274.glcanon_gl names the same layout
# through this alias, which is what keeps the two modules' strides one
# statement rather than two comments asking each other to agree. It is what
# 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 = 8
WideVerts = npt.NDArray[np.float32] # (N, FLOATS_PER_VERTEX)
# The live backplot's layout: position(3 float32) and a uint32 holding a
# palette index in its high byte -> 16 bytes. Colour is not stored per vertex;
# the shader looks it up in the palette. The packed word is carried in a
# float32 column purely as a bit container - it is never read as a number, and
# the values involved (an index <= 7) can never form a NaN pattern that a copy
# might quiet. See :func:`backplot_vertices`, which is the only thing that
# writes it; the program has its own layout, below.
TRAJ_FLOATS_PER_VERTEX = 4
TrajectoryVerts = npt.NDArray[np.float32] # (N, TRAJ_FLOATS_PER_VERTEX)
# The Lambert-shaded solids' layout: position(3) + normal(3) float32, (N, 6),
# 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 16-byte layout above is.
#
# 20 bytes per vertex, in two arrays rather than one interleaved buffer:
#
# per plane position 3 x float32 = 12 B
# shared source line uint32 + kind/tool uint32 = 8 B
#
# The split is what lets foam - which draws the same program on two planes,
# ``XY`` at ``foam_z`` and ``UV`` at ``foam_w`` - store the line, kind and tool
# columns once for both. The positions cannot be shared with them: each plane
# is a different transform of the same moves, so each holds its own.
PLANE_DTYPE = np.dtype([('pos', '<f4', (3,))])
ATTR_DTYPE = np.dtype([('line', '<u4'), ('kindtool', '<u4')])
VERTEX_STRIDE = PLANE_DTYPE.itemsize + ATTR_DTYPE.itemsize # 20
# The kind/tool word: kind in the low 8 bits, tool ordinal in the next 16.
# The top 8 are spare and are asserted zero rather than left unspecified -
# an unspecified bit is a bit some later reader will find a use for and a
# still later one will find already used.
KIND_MASK = 0xFF
TOOL_SHIFT = 8
TOOL_MASK = 0xFFFF
MAX_TOOL_ORDINAL = TOOL_MASK
SPARE_MASK = 0xFF000000
# Where a source line's vertices live in a buffer: ``{lineno: [(first, n)]}``.
# The wide-format parts still carry one; the program array replaced it with
# parallel arrays searched by ``np.searchsorted`` (see
# :meth:`ProgramGeometry.spans_for_line`).
LineRanges = dict[int, list[tuple[int, int]]]
# A resolved colour: r, g, b, a in 0..1.
RGBA = tuple[float, float, float, float]
# ---------------------------------------------------------------------------
# The staging chunk :meth:`ProgramGeometry.add_moves_raw` reads: one row per
# move, ``float64``, columns [lineno, p1 (9), p2 (9), feedrate, offset (3)] -
# 23 floats. This is what ``GLCanon`` writes ``rotate_and_translate``'s result
# into directly, in place of a move tuple, and it is also the shape a
# C-delivered batch would arrive in (see add-move-batch-protocol): the
# traverse/feed arity difference the tuple form carries (feed has a rate,
# traverse does not) is flattened here into one fixed width, with an unused
# feedrate slot on a traverse row - the same choice a fixed-width C struct
# would make.
STAGE_LINE = 0
STAGE_P1 = slice(1, 10)
STAGE_P2 = slice(10, 19)
STAGE_FEEDRATE = 19
STAGE_OFFSET = slice(20, 23)
STAGING_ROW_WIDTH = 23
# What a part hands the renderer: ``dict(name, kind, ...)``. Deliberately not
# a TypedDict - the key set varies by ``kind`` and the renderer reads it with
# ``.get()``; see :func:`program_parts`.
BakedPart = dict[str, Any]
class RotationOffsets:
"""Mirror of the C global ``roffsets`` (from ``gui_respect_offsets``).
``respect_offsets`` selects the offset-respecting rotation branch; when true
the A/B/C mask bits are set for each rotary letter present in ``coords``.
A/B/C in the GEOMETRY string only rotate when the matching bit is set.
"""
def __init__(self, respect_offsets: bool = False, coords: str = "",
x: float = 0.0, y: float = 0.0, z: float = 0.0) -> 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
def _box_of(*point_arrays: Float64Points
) -> tuple[Float64Points, Float64Points]:
"""``(min, max)`` over the first three columns, taken one column at a time.
numpy's ``axis=0`` reduction vectorises the three-wide row, not the k-long
column: 258 us against 22 us at k=16384. Same answer, either way - which
is why this exists as one named helper rather than four open-coded loops.
Several arrays reduce into one box without concatenating them, since the
copy costs more than the reduction it saves.
Every caller has at least one row; an empty batch never reaches the fill.
"""
lo = np.empty(3, dtype=np.float64)
hi = np.empty(3, dtype=np.float64)
for j in range(3):
col = point_arrays[0][:, j]
col_lo = col.min()
col_hi = col.max()
for arr in point_arrays[1:]:
col = arr[:, j]
col_lo = min(col_lo, col.min())
col_hi = max(col_hi, col.max())
lo[j] = col_lo
hi[j] = col_hi
return lo, hi
def _seg_lengths(p1: Float64Points, p2: Float64Points) -> Float64Points:
"""Each move's XYZ segment length, over the three columns it reads.
Bit-identical to ``np.linalg.norm((p2 - p1)[:, :3], axis=1)``, which
subtracted all nine columns to use three and then took a row-wise
reduction over a three-wide array. ``norm`` reduces the three squares in
this same order, so the sum - and therefore the root - is the same float.
"""
dx = p2[:, 0] - p1[:, 0]
dy = p2[:, 1] - p1[:, 1]
dz = p2[:, 2] - p1[:, 2]
return np.sqrt(dx * dx + dy * dy + dz * dz)
#: Runs of one commanded feed rate that :meth:`ProgramGeometry.
#: _accumulate_lengths` will find by walking rate changes rather than by
#: sorting the batch. Past it the run form's ``unique`` + ``repeat`` costs more
#: than the sort it replaces, so the sort runs instead. A **cost** switch, not
#: a correctness one: both branches feed the same ``bincount`` the same bins
#: and produce the same table bit for bit, so a wrong value here costs time and
#: nothing else.
_RATE_RUN_LIMIT = 64
class ProgramGeometry:
"""The parsed program, as arrays. The authoritative record of what it is.
It is the program record and the ready-to-go GPU's source data at once.
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 (the transformed position,
which is plane-specific because the transform is) and one shared
:data:`ATTR_DTYPE` array (source line, and the packed kind/tool word). Both
grow by doubling; the move count is not known in advance and a counting
pass would mean holding or re-walking the source.
**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)
#: 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
@property
def capacity(self) -> int:
"""Vertices the arrays can hold before the next :meth:`_reserve`.
Reported rather than inferred because the doubling growth means it is
anywhere between :attr:`n_vertices` and twice it. Note that the
difference is address space and not resident memory: :meth:`_reserve`
allocates with ``np.empty`` and only the written prefix is ever
touched, so the unused tail is never faulted in.
"""
return len(self._attrs)
@property
def nbytes(self) -> int:
"""Bytes the position and attribute arrays span, slack included.
Address span, not resident memory - see :attr:`capacity`. Reading this
as RAM overstates a grown array by up to a factor of two.
"""
return int(sum(a.nbytes for a in self._planes) + self._attrs.nbytes)
def plane_array(self, plane: int = 0) -> npt.NDArray[Any]:
"""The ``(N,)`` :data:`PLANE_DTYPE` array for one drawn plane."""
return self._planes[plane][:self._n]
@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]
@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 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.
turning = _any_rotary_change(p1, p2)
steps = _rotary_steps_batch(p1, p2) if turning else None
# Nothing has been drawn yet: the first move's start point is a jump
# by definition, which is also what starts the strip.
first_jump = (True if self._cur9 is None
else bool(np.any(p1[0] != self._cur9)))
# p2[:-1] is the previous move's end point, so this is the whole
# continuity test; the per-move flags are built only when it fails.
continuous = not first_jump and bool(np.array_equal(p1[1:], p2[:-1]))
# 3. Expand. ``t == 0`` reproduces p1 exactly, which is what makes the
# jump record fall out of the same expression as the interpolation
# rather than needing a branch.
#
# 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.
if not turning and continuous:
# Nothing turning, nothing jumping: the vertices are the moves' end
# points, and every index array below would be the identity.
pts9 = p2
line_col = lines.astype(np.uint32)
kind_col = kind_in
else:
if continuous:
jump = np.zeros(k, dtype=bool)
else:
jump = np.empty(k, dtype=bool)
jump[1:] = np.any(p1[1:] != p2[:-1], axis=1)
jump[0] = first_jump
if steps is None:
steps = np.ones(k, dtype=np.int64)
counts = steps + jump
total = int(counts.sum())
ends = np.cumsum(counts)
move_idx = np.repeat(np.arange(k), counts)
within = np.arange(total) - (ends - counts)[move_idx]
sub = within - jump[move_idx] + 1
if steps.max() == 1 and not jump.any():
pts9 = p2 # the common case: no copy at all
else:
t = (sub / steps[move_idx])[:, np.newaxis]
pts9 = t * p2[move_idx] + (1.0 - t) * p1[move_idx]
line_col = lines[move_idx].astype(np.uint32)
kind_col = kind_in[move_idx].copy()
kind_col[sub == 0] = KIND_NOOP
# 5. One transform per plane per batch, never one per move.
points = [transform_points(pts9, geom, self.ro)
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]
arr['pos'][n:n + m] = pos
lo, hi = _box_of(pos)
np.minimum(self._drawn[0], lo, out=self._drawn[0])
np.maximum(self._drawn[1], hi, out=self._drawn[1])
self._attrs['line'][n:n + m] = line_col
# Into the array's own field, tool ordinal or-ed on there: no
# batch-sized temporaries. Must not mutate kind_col - the unsubdivided
# path hands over the canon's pending kinds themselves, which can be a
# read-only ``frombuffer`` view.
field = self._attrs['kindtool'][n:n + m]
np.copyto(field, kind_col)
field |= np.uint32(self._tool) << np.uint32(TOOL_SHIFT)
self._n = n + m
self._index = None
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
# -- 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.
Of the four pairs, only the first is always reduced. The other three
are derived from it where the batch permits, and reduced in full where
it does not - each derivation has its general form as the else branch
beside it, so a batch containing a tool change, or filled under a g5x
rotation, is answered exactly as it was before this shortcut existed.
"""
raw = _box_of(p1, p2)
one_offset = bool((offsets == offsets[0]).all())
if one_offset:
# One offset for the whole batch - i.e. no tool change in it - so
# the corrected box is the raw box shifted. Adding a constant is
# monotonic, so this is the same box, not an approximation of it.
shift = offsets[0]
notool = (raw[0] + shift, raw[1] + shift)
else:
notool = _box_of(p1[:, :3] + offsets, p2[:, :3] + offsets)
if not rotation_xy:
# No rotation to remove: these two pairs are the two above.
rot, rot_notool = raw, notool
else:
r1 = _unrotate_xy(p1[:, :3], rotation_xy, g5x_xy)
r2 = _unrotate_xy(p2[:, :3], rotation_xy, g5x_xy)
rot = _box_of(r1, r2)
if one_offset:
rot_notool = (rot[0] + shift, rot[1] + shift)
else:
rot_notool = _box_of(r1 + offsets, r2 + offsets)
for i, (lo, hi) in enumerate((raw, notool, rot, rot_notool)):
np.minimum(self._extents[i, 0], lo, out=self._extents[i, 0])
np.maximum(self._extents[i, 1], hi, out=self._extents[i, 1])
def _accumulate_lengths(self, p1: Float64Points, p2: Float64Points,
kind_in: npt.NDArray[np.uint8],
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.bincount`` over the distinct rates) rather than looped per move;
only the result - one Python dict update per *distinct rate in this
batch* - is a Python-level loop, which is what keeps the table bounded
by feed-rate changes rather than move count.
"""
seglen = _seg_lengths(p1, p2)
is_traverse = kind_in == CAT_TRAVERSE
n_traverse = int(is_traverse.sum())
if n_traverse == len(kind_in):
# Nothing cutting: no selection copy, and no table to update.
self._rapid_length += float(seglen.sum())
return
if n_traverse == 0:
# Nothing traversing: the batch *is* its own cutting selection, so
# neither boolean index copy is made.
cut_rates, cut_lengths = feedrates, seglen
else:
self._rapid_length += float(seglen[is_traverse].sum())
cutting = ~is_traverse
cut_rates = feedrates[cutting]
cut_lengths = seglen[cutting]
# A commanded rate holds for a run of moves, so sort the runs, not the
# batch. Same bincount over the same bins, so the table is unchanged
# bit for bit. Past the limit the run form costs more than the sort.
starts = np.concatenate(
([0], np.flatnonzero(cut_rates[1:] != cut_rates[:-1]) + 1))
if len(starts) <= _RATE_RUN_LIMIT:
uniq, run_bins = np.unique(cut_rates[starts], return_inverse=True)
inverse = np.repeat(
run_bins, np.diff(np.append(starts, len(cut_rates))))
else:
uniq, inverse = np.unique(cut_rates, return_inverse=True)
sums = np.bincount(inverse, weights=cut_lengths, minlength=len(uniq))
for rate, length in zip(uniq.tolist(), sums.tolist()):
self._cut_length_by_feed[rate] = (
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.
``offsets`` are reported, not applied, exactly as they are for the
trajectory. The markers are small enough that a translation here would cost
nothing; they follow the same rule so that the offset has one home. Two
places applying it is how a marker and the path it marks come to sit at
different heights after a later change to only one of them.
Both planes use the marker's own colour: unlike the trajectory, which has
``straight_feed_xy``/``_uv`` variants, the colour table has no per-plane
entry for a dwell, and the canon stores the resolved colour rather than
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:
log.warning("glcanon: more dwell colours than the palette holds; the "
"excess draw in the last entry's colour")
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)
planes.append(arr)
padded = list(entries) + [(0.0, 0.0, 0.0, 1.0)] * (PALETTE_SIZE
- len(entries))
return {"name": "dwell", "kind": "program_array",
"planes": planes, "attrs": attrs,
"palettes": [padded] * n_planes,
"plane_offsets": tuple(offsets[:n_planes]),
"mode": MODE_LINES,
# No record kinds here, and no rapid to hide: every entry is a
# colour, so the whole palette is drawable.
"hide_cat": -1,
"last_drawn_kind": PALETTE_SIZE - 1,
"spans": _spans_from_pairs(
[ln for j, ln in enumerate(linenos) if j % 2 == 0], 2)}
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 *reported* here rather than applied, and neither
the fill nor this stores them in a position. ``foam_z``/``foam_w`` say
where a plane is drawn, not what the program is: they can still move while
the program is being parsed (an ``(AXIS,XY_Z_POS)`` comment sets them), and
they are a rigid translation, so they belong in the draw's matrix. The
buffer applies them once for every pass it can be drawn in.
That is also what makes this function free of copies. Every array here is
the one the fill wrote, handed to ``glBufferData`` as a view, so the
process is not holding a second copy of the program at the moment the
driver allocates the first - which on a Pi is the same pool of memory.
"""
suffixes = ("_xy", "_uv") if is_foam else ("",)
offsets = (foam_z, foam_w) if is_foam else (0.0,)
planes = [geometry.plane_array(i) for i in range(len(offsets))]
palettes = [palette(colors, suffix) for suffix in suffixes]
return [
{"name": "program", "kind": "program_array",
"planes": planes, "attrs": geometry.attrs, "palettes": palettes,
"plane_offsets": offsets,
# The program is the buffer that nominates a hidden kind - its rapid
# code - and every other buffer nominates none. Rapids draw solid:
# LinuxCNC removed GL_LINE_STIPPLE from the rapid traverse and the
# soft-limit wireframe deliberately (f1c1209f52, "unreliable on some
# graphics cards"), so there is nothing left in this renderer that
# dashes, and no attribute or uniform to support it.
"hide_cat": KIND_TRAVERSE,
"last_drawn_kind": LAST_DRAWN_KIND,
# Passed as a callable, not as the index itself: ``index`` builds on
# first mention, and the only thing that reads it is the highlight.
# Naming it here would build it at load, where its full-length
# temporaries peak a few statements before the driver is asked for the
# buffer - and for a program nobody clicks, never be read at all.
"mode": MODE_LINE_STRIP, "spans": lambda: geometry.index},
dwell_marker_part(geometry, is_lathe, cross, offsets),
]
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 _any_rotary_change(p1: Float64Points, p2: Float64Points) -> bool:
"""Whether any move turns A, B or C.
Short-circuits per column, so a 3-axis batch answers no in three
comparisons and never builds the per-move subdivision count at all. The
equality it tests is the same one :func:`_rotary_steps_batch` reduces, so
the two cannot disagree about whether a batch turns.
"""
for col in (3, 4, 5):
if not np.array_equal(p1[:, col], p2[:, col]):
return True
return False
def _rotary_steps_batch(p1: Float64Points,
p2: Float64Points) -> npt.NDArray[np.int64]:
"""Points each move contributes: 1, or the rotary subdivision count.
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 ``<name>_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 16-byte palette-indexed vertex (:data:`TrajectoryVerts`),
# and nothing else - it reads no ``ProgramGeometry`` and is filled by no canon.
# The backplot's own packing convention for the 16-byte vertex's uint32 word:
# the palette index in the high byte, the rest zero. It is not a source line
# number and never was - the backplot is neither picked nor highlighted - so
# the field the program array gives to the line number is simply unused here.
#
# 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', '<f4', (3,)), ('c', 'u1', (4,)),
('pos2', '<f4', (3,)), ('c2', 'u1', (4,)),
])
def backplot_vertices(raw: Any, npts: int, is_xyuv: bool,
first_point: int = 0,
palette: Optional[ColorPalette] = None
) -> 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 16-byte layout: ``(M, TRAJ_FLOATS_PER_VERTEX)``, the
index packed into the word whose line-number bits stay zero. The backplot
is not picked, so those bits are never read.
The palette is keyed on the **stored bytes**, not on the preview's colour
table. The C resolves a motion type to a ``struct color`` of four uint8
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