# This is a component of AXIS, a front-end for emc # Copyright 2004, 2005, 2006 Jeff Epler # Copyright 2026 Alexey Presniakov <309782758+alex-pres@users.noreply.github.com> # Two further groups of helpers ride along at the end of the file. Neither # is program geometry, but both are baked the same way - vertex arrays built # with no OpenGL call in sight - and both are only ever fed to the same # renderer: the Lambert-shaded tool solids, which are constant meshes # parameterised by a radius and a height rather than anything the canon # records, and the live backplot, which is the path the machine has actually # travelled, streamed out of the C position logger's ring buffer while a # program runs. The backplot shares the program's *shader* and its 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', ' 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 ``_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', ' 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