From 2ad450b9038fde5bf502cce12b2dffbf74077552 Mon Sep 17 00:00:00 2001 From: Alexey Presnyakov <309782758+alex-pres@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:50:58 +0400 Subject: [PATCH] preview: draw stock outlines from (WORKPIECE,...) comments A (WORKPIECE,BOX|CYLINDER|TUBE,...) comment declares the stock the program is cut from; every preview host draws it as a translucent wireframe, toggled per host, and get_workpieces() hands a GUI the declared params and the outline in machine coordinates. Program extents are unchanged. --- docs/src/gcode/overview.adoc | 62 +++ docs/src/gui/axis.adoc | 3 + lib/python/qtvcp/widgets/gcode_graphics.py | 8 + lib/python/rs274/glcanon.py | 36 ++ lib/python/rs274/glcanon_scene.py | 434 ++++++++++++++++++++- src/emc/usr_intf/gremlin/gremlin.py | 2 + src/emc/usr_intf/gremlin/qt5_graphics.py | 2 + tests/glcanon/test.sh | 14 +- tests/glcanon/test_workpiece.py | 256 ++++++++++++ 9 files changed, 813 insertions(+), 4 deletions(-) create mode 100644 tests/glcanon/test_workpiece.py diff --git a/docs/src/gcode/overview.adoc b/docs/src/gcode/overview.adoc index 683f72cc154..76b8e2dd5eb 100644 --- a/docs/src/gcode/overview.adoc +++ b/docs/src/gcode/overview.adoc @@ -1161,6 +1161,68 @@ The formatting string does not need to be right beside the parameter. If the formatting string is created with the wrong pattern it will be printed as characters. +[[gcode:workpiece]] +== Workpiece + +(((Workpiece))) +A '(WORKPIECE,...)' comment declares the stock the program is cut from. The +G-code preview draws it as a wireframe outline around the toolpath; it has no +effect on machine behaviour, on the program extents, or on zoom-to-fit. + +Three shapes are understood: + +[source,ngc] +---- +(WORKPIECE,BOX,XMIN=0,YMIN=0,ZMIN=-40,XMAX=100,YMAX=100,ZMAX=0) +(WORKPIECE,CYLINDER,AXIS=Z,X=0,Y=0,ZMIN=-40,ZMAX=0,DIAMETER=80) +(WORKPIECE,TUBE,AXIS=Z,X=0,Y=0,ZMIN=-40,ZMAX=0,DIAMETER=80,INNER_DIAMETER=40) +---- + +Keys may appear in any order and are case-insensitive, as are the shape names. + +.Keys +[width="90%",options="header",cols="2,1,4"] +|=== +|Key |Shapes |Meaning +|'XMIN' 'YMIN' 'ZMIN' 'XMAX' 'YMAX' 'ZMAX' |BOX |The two opposite corners. All + six are required, and no 'MIN' may exceed its 'MAX'. +|'AXIS' |CYLINDER, TUBE |The axis of revolution, 'X', 'Y' or 'Z'. Optional, + default 'Z'. +|'MIN' 'MAX' |CYLINDER, TUBE |The extent along 'AXIS' - so + 'ZMIN'/'ZMAX' for 'AXIS=Z'. Both required. +|the other two axis letters |CYLINDER, TUBE |Where the axis sits in the other + two directions - 'X' and 'Y' for 'AXIS=Z'. Optional, default 0. +|'DIAMETER' |CYLINDER, TUBE |The outside diameter; required, and always a + diameter, never a radius, regardless of lathe diameter mode (G7). +|'INNER_DIAMETER' |TUBE |The bore diameter; required, and must be greater than + zero and less than 'DIAMETER'. +|'UNITS' |all |'MM' or 'INCH'. Optional; without it the values are read in the + units modal at the comment (G20/G21). +|=== + +Several '(WORKPIECE,...)' comments are additive: each one adds a piece of +stock, so a fixture holding several parts can be described with one comment +per piece. + +The coordinates are read in the coordinate system active where the comment +appears - the G92 offset, the G5x XY rotation and the G5x offset in force at +that line, exactly as for a move endpoint on the same line. There is no key to +name a coordinate system; a post processor must therefore emit the comment +'after' the work offset it belongs to: + +[source,ngc] +---- +G54 +(WORKPIECE,BOX,XMIN=0,YMIN=0,ZMIN=-40,XMAX=100,YMAX=100,ZMAX=0) +---- + +Unknown keys are ignored, so a comment written for a later version of LinuxCNC +still draws what this one understands. Anything else malformed - a missing +required key, a value that is not a number, an unrecognised shape - is ignored +with a warning, and the rest of the file is interpreted normally. A workpiece +declared between '(AXIS,hide)' and '(AXIS,show)' is still drawn: those comments +suppress moves, and the stock is not a move. + [[gcode:file-requirements]] == File Requirements diff --git a/docs/src/gui/axis.adoc b/docs/src/gui/axis.adoc index 81c50d8e6d5..f7749516653 100644 --- a/docs/src/gui/axis.adoc +++ b/docs/src/gui/axis.adoc @@ -992,6 +992,9 @@ preview on certain parts that are already working OK). This display can be useful in the AXIS preview when (debug,message) comments are not displayed. +The preview can also draw the stock the program is cut from; see the +<> section. + [[axis:touchoff-actual]] === Touch Off using Actual Position The Touch Off feature can optionally incorporate the actual axis position value into the calculation for the offset. This is primarily used in cases where a non-motorized axis such as the quill in a milling machine provides feedback to LinuxCNC via an encoder, but there is no motor to control movement. This allows AXIS to provide a DRO display for such an axis with working touch off capability. diff --git a/lib/python/qtvcp/widgets/gcode_graphics.py b/lib/python/qtvcp/widgets/gcode_graphics.py index fcea06aa5db..b14bc465bec 100644 --- a/lib/python/qtvcp/widgets/gcode_graphics.py +++ b/lib/python/qtvcp/widgets/gcode_graphics.py @@ -441,6 +441,14 @@ def getShowOffsets(self): return self.show_offsets _offsets = Property(bool, getShowOffsets, setShowOffsets) + # show workpiece + def setShowWorkpiece(self, state): + self.show_workpiece = state + self.update() + def getShowWorkpiece(self): + return self.show_workpiece + _workpiece = Property(bool, getShowWorkpiece, setShowWorkpiece) + # show small origin def setShowSmallOrigin(self, state): self.show_small_origin = state diff --git a/lib/python/rs274/glcanon.py b/lib/python/rs274/glcanon.py index c20a6cbf51e..0c32c87b3ae 100644 --- a/lib/python/rs274/glcanon.py +++ b/lib/python/rs274/glcanon.py @@ -163,8 +163,20 @@ def __init__(self, colors, geometry, is_foam=0, foam_w=1.5, foam_z=0.0): self.notify = 0 self.notify_message = "" self.highlight_line = None + # Stock outlines declared by (WORKPIECE,...) comments, in the order + # declared - they are additive, one entry per comment, because a + # fixture may hold several pieces. A canon is built per file load, so + # nothing else ever clears this. + self.workpieces = [] def comment(self, arg): + if arg.startswith("WORKPIECE,"): + # Recorded even while (AXIS,hide) is in force: hide suppresses + # moves, and the stock is not a move. + workpiece = glcanon_scene.Workpiece.from_comment(arg, self) + if workpiece is not None: + self.workpieces.append(workpiece) + return if arg.startswith("AXIS,") or arg.startswith("PREVIEW,"): parts = arg.split(",") command = parts[1] @@ -655,6 +667,8 @@ class GlCanonDraw: 'axis_y': (1.00, 0.20, 0.20), 'grid': (0.15, 0.15, 0.15), 'limits': (1.0, 0.0, 0.0), + 'workpiece': glcanon_scene.WORKPIECE_COLOR, + 'workpiece_alpha': glcanon_scene.WORKPIECE_ALPHA, } def __init__(self, s=None, lp=None, g=None): self.stat = s @@ -1117,6 +1131,27 @@ def get_grid(self): if self.canon and self.canon.grid: return self.canon.grid return 5./25.4 + def get_show_workpiece(self): + """Whether (WORKPIECE,...) stock outlines are drawn. + + Defaulted here rather than required of every host: the other show_* + getters predate this widget and each of the hosting GUIs defines its + own, so a host that has never heard of the flag keeps drawing stock, + and any host can toggle it by setting self.show_workpiece.""" + return getattr(self, 'show_workpiece', True) + + def get_workpieces(self): + """The stock the loaded program declared, as rs274.glcanon_scene + .Workpiece records - the declared params, the outline in machine + coordinates, and the outline as drawn. Empty until a program is + loaded. + + The supported way for a hosting GUI to read them: self.canon is None + before the first load and is replaced by every one, so a handler that + took it once would go stale silently. + """ + return getattr(self.canon, 'workpieces', []) if self.canon else [] + def draw_grid(self): """Draw the ground grid. Override point: plasmac2 replaces this method on the instance and calls back into draw_grid_permuted.""" @@ -1243,6 +1278,7 @@ def frame_context(self) -> glcanon_scene.FrameContext: show_relative=self.get_show_relative(), show_metric=self.get_show_metric(), show_small_origin=self.show_small_origin, + show_workpiece=self.get_show_workpiece(), program_alpha=self.get_program_alpha(), grid_size=self.get_grid_size(), highlight_line=self.get_highlight_line(), diff --git a/lib/python/rs274/glcanon_scene.py b/lib/python/rs274/glcanon_scene.py index ead97fc3c93..664c23dfc0c 100644 --- a/lib/python/rs274/glcanon_scene.py +++ b/lib/python/rs274/glcanon_scene.py @@ -56,8 +56,8 @@ import glnav import linuxcnc from rs274 import glcanon_bake, glcanon_gl -from rs274.glcanon_bake import (LineRanges, MeshVerts, TrajectoryVerts, - WideVerts) +from rs274.glcanon_bake import (Float64Points, LineRanges, MeshVerts, + TrajectoryVerts, WideVerts) from rs274.glcanon_gl import ProgramBuffers, set_line_width log = logging.getLogger(__name__) @@ -84,6 +84,20 @@ #: current view draws in, and back. ``GridPart`` builds one pair per view. Permutation = Callable[[Sequence[float]], tuple[float, float, float]] +#: The stock outline's colour, and the single source for the ``'workpiece'`` +#: entry of ``rs274.glcanon.GlCanonDraw.colors``. Amber/tan reads as raw +#: material and is far enough from every other line colour in the preview to +#: be told apart at a glance - the nearest, the orange tool-change marker, is +#: a marker rather than a wireframe and never appears alongside a face of one. +WORKPIECE_COLOR = (0.80, 0.55, 0.25) + +#: How opaque that outline is drawn. The stock is context for the toolpath, +#: not a subject of its own: at full opacity a box drawn around the whole +#: program competes with it, and its far edges read as part of the path. Held +#: back far enough to sit behind the program without disappearing on the +#: default black background. +WORKPIECE_ALPHA = 0.4 + def minmax(*args: float) -> tuple[float, float]: return min(*args), max(*args) @@ -197,6 +211,7 @@ class FrameContext: 'view', 'width', 'height', 'show_program', 'show_rapids', 'show_extents', 'show_offsets', 'show_limits', 'show_tool', 'show_live_plot', 'show_relative', 'show_metric', 'show_small_origin', + 'show_workpiece', 'program_alpha', 'grid_size', 'highlight_line', 'enable_dro', 'cone_basesize', 'disable_cone_scaling', 'view_tool_min_dia', # callables: overridable hooks and lazily-needed values @@ -273,6 +288,7 @@ class FrameContext: show_relative: bool show_metric: bool show_small_origin: bool + show_workpiece: bool program_alpha: bool #: Ground-grid spacing in internal units; ``0`` means "no grid", and is the #: grid part's visibility gate. @@ -2030,6 +2046,417 @@ def draw(self, ctx: FrameContext) -> None: ypos -= linespace +def _bounds(points: Float64Points) -> tuple[tuple[float, ...], + tuple[float, ...]]: + """``(min_xyz, max_xyz)`` of an ``(N, 3)`` array, as plain floats. + + ``tolist()`` rather than ``tuple(arr.min(0))``: the latter holds numpy + scalars, which repr and format differently from the plain floats every + other number a caller reads off the canon is. + """ + return (tuple(points.min(axis=0).tolist()), + tuple(points.max(axis=0).tolist())) + + +class _BadWorkpiece(ValueError): + """A ``(WORKPIECE,...)`` comment that cannot be drawn. + + Never escapes :meth:`Workpiece.from_comment`: it carries the reason into + the one warning that comment gets. A malformed comment must never stop a + parse - the g-code is still perfectly runnable, only the stock outline is + not drawable. + """ + + +class Workpiece: + """One stock solid declared by a ``(WORKPIECE,...)`` comment. + + Three views of the same declaration, because three different callers want + it: :attr:`params`, what the comment said, in the frame it was written in; + :attr:`machine_points`, where that lands on the machine; and + :attr:`points`, the display-space wireframe the preview draws. A GUI + asking "what stock is this program for, and where is it clamped?" - to + check it against a fixture, to drive a probe, to fill a setup sheet - + needs the first two, and cannot recover either from the last. + + All three are resolved during the parse, not on demand, because the + coordinate frame the comment was written in - the g92/rotation/g5x offsets + active at that line - is gone afterwards: the parse walks on and changes + them, and a workpiece asked about later would answer in whatever frame the + file happened to end in. The same reason the moves are transformed on the + way in. + + Wireframe only. The stock is context for the toolpath, not a subject of + its own, so it goes through the plain line path: no mesh, no lighting, and + it is invisible to picking (which is ``ProgramResource``-only). + + Read them off the widget, which holds the canon of the last program it + loaded:: + + for wp in gremlin_widget.get_workpieces(): + print(wp.shape, wp.params, wp.machine_extents) + """ + + __slots__ = ('shape', 'params', 'lineno', 'machine_points', 'points') + + #: Segments per end circle. 36 is the legacy preview's arc resolution and + #: is smooth enough at any zoom the preview offers. + CIRCLE_SEGMENTS = 36 + + #: Keys naming the box corners. All six are required: a stock block with a + #: guessed dimension would be drawn confidently and be wrong. + BOX_KEYS = ('XMIN', 'YMIN', 'ZMIN', 'XMAX', 'YMAX', 'ZMAX') + + def __init__(self, shape: str, params: dict[str, Any], lineno: int, + machine_points: Float64Points, + points: Float64Points) -> None: + #: ``'BOX'``, ``'CYLINDER'`` or ``'TUBE'``. + self.shape = shape + #: What the comment declared, by its own key names, with every linear + #: value converted to the units the canon counts in - inches, the same + #: units as ``canon.min_extents``, *not* the units the comment was + #: written in. Optional keys that were left out are present at their + #: default, so a reader never has to know which those are. Unknown + #: keys are not here: they were ignored, and reporting them would + #: invite a caller to depend on one this version does not implement. + self.params = params + #: Source line the comment was on, or ``-1`` if the canon was driven + #: without one (a unit test). + self.lineno = lineno + #: ``(N, 3)`` GL_LINES endpoints in absolute **machine** coordinates - + #: the g92 offset, the g5x XY rotation and the g5x offset that were + #: active at the comment, applied in that order, exactly as a move + #: endpoint on the same line gets them. No tool offset: stock is not + #: tool-dependent. Internal units, as everything else on the canon. + self.machine_points = machine_points + #: ``(N, 3)`` display-space GL_LINES endpoints, N even. These are + #: :attr:`machine_points` put through the GEOMETRY string, so they are + #: the same numbers on a plain ``XYZ`` machine and different ones + #: wherever GEOMETRY reorders, negates, or folds a rotary axis - a + #: lathe's ``XZ``, say. Draw from these; measure from the others. + self.points = points + + def __repr__(self) -> str: + return "" % (self.shape, self.lineno, + self.params) + + @property + def machine_extents(self) -> tuple[tuple[float, ...], tuple[float, ...]]: + """``(min_xyz, max_xyz)`` of the stock in machine coordinates. + + What a caller comparing stock against the machine wants - soft limits, + a fixture, a probe move - and the answer to "where is this stock + clamped", which :attr:`params` alone cannot give once a work offset is + involved. An axis-aligned box around the outline, so under a G10 L2 XY + rotation it is the box the rotated stock spans, not the stock. + """ + return _bounds(self.machine_points) + + @property + def extents(self) -> tuple[tuple[float, ...], tuple[float, ...]]: + """``(min_xyz, max_xyz)`` of the drawn outline, in display space. + + The preview's own frame, so this is what compares with + ``canon.min_extents``/``max_extents``. Deliberately not what those are + computed from: the stock does not move the program's extents (see + ``PreviewScene``). + """ + return _bounds(self.points) + + # -- parsing ----------------------------------------------------------- + # + # The grammar (all keys order-free, case-insensitive, values floats): + # + # WORKPIECE,BOX,XMIN=,YMIN=,ZMIN=,XMAX=,YMAX=,ZMAX=[,UNITS=MM|INCH] + # WORKPIECE,CYLINDER,AXIS=Z,X=,Y=,ZMIN=,ZMAX=,DIAMETER=[,UNITS=] + # WORKPIECE,TUBE,,INNER_DIAMETER=[,UNITS=] + # + # Unknown keys are ignored rather than rejected, so a post processor may + # emit a key a future LinuxCNC understands without this one refusing the + # whole comment. + + @classmethod + def from_comment(cls, arg: str, canon: Any) -> "Workpiece | None": + """The one entry point ``GLCanon.comment()`` calls. + + ``arg`` is the comment text without its parentheses, starting with + ``WORKPIECE,``. Returns ``None`` for anything malformed, after one + warning naming the comment; it never raises. + """ + try: + shape, keys = cls._tokenize(arg) + scale, bad_units = cls._unit_scale(keys.pop('UNITS', None), canon) + if shape == 'BOX': + params, points = cls._box_from_keys(keys, scale) + elif shape in ('CYLINDER', 'TUBE'): + params, points = cls._cylinder_from_keys(keys, scale, + tube=shape == 'TUBE') + else: + raise _BadWorkpiece("unknown shape %r" % shape) + except _BadWorkpiece as exc: + log.warning("ignoring workpiece comment (%s): (%s)", exc, arg) + return None + # Every key the shape understands is in params, defaults included, so + # what is left over is exactly what this version did not understand. + ignored = sorted(set(keys) - set(params)) + if bad_units: + ignored.append('UNITS') + if ignored: + log.warning("workpiece comment: ignoring %s: (%s)", + ", ".join(ignored), arg) + machine_points = cls._to_machine(points, canon) + return cls(shape, params, getattr(canon, 'lineno', -1), machine_points, + cls._to_display(machine_points, canon)) + + @staticmethod + def _tokenize(arg: str) -> tuple[str, dict[str, str]]: + """``(shape, {KEY: raw value})`` from the comment text.""" + tokens = [t.strip() for t in arg.split(',')] + if len(tokens) < 2 or not tokens[1]: + raise _BadWorkpiece("no shape") + keys = {} + for token in tokens[2:]: + if not token: + continue + key, sep, value = token.partition('=') + if not sep: + raise _BadWorkpiece("%r is not KEY=VALUE" % token) + keys[key.strip().upper()] = value.strip() + return tokens[1].upper(), keys + + @staticmethod + def _unit_scale(units: str | None, canon: Any) -> tuple[float, bool]: + """``(factor, units key was junk)`` taking the comment's linear values + to the units the canon counts in. + + Those are LinuxCNC's internal linear units - inches - whatever the + machine is set to: the hosts hand the interpreter a ``G20``/``G21`` + chosen from the machine, and what reaches the canon is already + converted, so a 100 mm program on a millimetre machine gives the canon + extents 3.937. Only the *program's* units are in question here, which + is why this asks nothing about the machine - the same rule, and the + same divisor, as the foam Z comments in ``rs274.glcanon``. + """ + bad = False + program_mm = None + if units is not None: + word = units.upper() + if word == 'MM': + program_mm = True + elif word == 'INCH': + program_mm = False + else: + bad = True + if program_mm is None: + state = getattr(canon, 'state', None) + # 210 is G21 (mm) in the interpreter's modal g-code list. + program_mm = 210 in getattr(state, 'gcodes', ()) + return (1.0 / 25.4 if program_mm else 1.0), bad + + @staticmethod + def _num(keys: dict[str, str], name: str, scale: float, + default: float | None = None) -> float: + """One linear value in canon units. ``default=None`` means required.""" + raw = keys.get(name) + if raw is None: + if default is None: + raise _BadWorkpiece("missing %s" % name) + return default * scale + try: + return float(raw) * scale + except ValueError: + raise _BadWorkpiece("%s=%r is not a number" % (name, raw)) from None + + @classmethod + def _box_from_keys(cls, keys: dict[str, str], + scale: float) -> tuple[dict[str, Any], Float64Points]: + v = {name: cls._num(keys, name, scale) for name in cls.BOX_KEYS} + for letter in 'XYZ': + if v[letter + 'MIN'] > v[letter + 'MAX']: + raise _BadWorkpiece("%sMIN > %sMAX" % (letter, letter)) + return v, cls.box_edges(v['XMIN'], v['YMIN'], v['ZMIN'], + v['XMAX'], v['YMAX'], v['ZMAX']) + + @classmethod + def _cylinder_from_keys(cls, keys: dict[str, str], scale: float, + tube: bool) -> tuple[dict[str, Any], + Float64Points]: + """A cylinder or tube about ``AXIS``. + + The two centre keys are the *other* two axis letters, so a lathe's + ``AXIS=Z,X=0,Y=0`` reads as it would be written by hand; the range + keys are the axis letter's own ``MIN``/``MAX``. + """ + word = keys.get('AXIS', 'Z').upper() + if word not in ('X', 'Y', 'Z'): + raise _BadWorkpiece("AXIS=%r is not X, Y or Z" % keys['AXIS']) + axis = 'XYZ'.index(word) + c_names = [letter for letter in 'XYZ' if letter != word] + amin = cls._num(keys, word + 'MIN', scale) + amax = cls._num(keys, word + 'MAX', scale) + if amin > amax: + raise _BadWorkpiece("%sMIN > %sMAX" % (word, word)) + c1 = cls._num(keys, c_names[0], scale, default=0.0) + c2 = cls._num(keys, c_names[1], scale, default=0.0) + # Always a diameter, never a radius - G7 lathe diameter mode does not + # reach here, and a key that meant two different things by mode would + # be unusable from a post processor. + diameter = cls._num(keys, 'DIAMETER', scale) + if diameter <= 0: + raise _BadWorkpiece("DIAMETER must be positive") + params: dict[str, Any] = { + 'AXIS': word, word + 'MIN': amin, word + 'MAX': amax, + c_names[0]: c1, c_names[1]: c2, 'DIAMETER': diameter} + points = cls.cylinder_edges(axis, c1, c2, amin, amax, diameter / 2.0) + if tube: + inner = cls._num(keys, 'INNER_DIAMETER', scale) + if not 0 < inner < diameter: + raise _BadWorkpiece("INNER_DIAMETER must be between 0 and " + "DIAMETER") + params['INNER_DIAMETER'] = inner + points = np.vstack((points, + cls.tube_bore_edges(axis, c1, c2, amin, amax, + inner / 2.0))) + return params, points + + # -- edge builders ----------------------------------------------------- + # + # All return GL_LINES endpoints in the shape's own (program) coordinates: + # every consecutive pair is one segment, as Primitives.draw_cube writes + # them. The corners are precomputed rather than drawn from the extents at + # draw time because each one has to go through the offset and rotation + # transform below. + + @staticmethod + def box_edges(xmin: float, ymin: float, zmin: float, + xmax: float, ymax: float, zmax: float) -> Float64Points: + """The 12 edges of an axis-aligned box, as 24 endpoints.""" + return np.array([ + # bottom + (xmin, ymin, zmin), (xmax, ymin, zmin), + (xmax, ymin, zmin), (xmax, ymax, zmin), + (xmax, ymax, zmin), (xmin, ymax, zmin), + (xmin, ymax, zmin), (xmin, ymin, zmin), + # top + (xmin, ymin, zmax), (xmax, ymin, zmax), + (xmax, ymin, zmax), (xmax, ymax, zmax), + (xmax, ymax, zmax), (xmin, ymax, zmax), + (xmin, ymax, zmax), (xmin, ymin, zmax), + # verticals + (xmin, ymin, zmin), (xmin, ymin, zmax), + (xmax, ymin, zmin), (xmax, ymin, zmax), + (xmax, ymax, zmin), (xmax, ymax, zmax), + (xmin, ymax, zmin), (xmin, ymax, zmax), + ], dtype=np.float64) + + @classmethod + def cylinder_edges(cls, axis: int, c1: float, c2: float, amin: float, + amax: float, radius: float) -> Float64Points: + """Two end circles plus four longitudinals, as 2*(2N)+8 endpoints. + + ``axis`` is ``X``/``Y``/``Z``; ``c1``/``c2`` centre it in the other + two, taken in X, Y, Z order. + """ + i1, i2 = [i for i in (X, Y, Z) if i != axis] + # Four longitudinals is what reads as a cylinder from any view without + # the silhouette turning into a solid band edge-on. + angles = np.arange(4) * (math.pi / 2.0) + sides = np.empty((8, 3), dtype=np.float64) + sides[0::2, axis] = amin + sides[1::2, axis] = amax + for column, value in ((i1, c1 + radius * np.cos(angles)), + (i2, c2 + radius * np.sin(angles))): + sides[0::2, column] = value + sides[1::2, column] = value + return np.vstack((cls.circle_edges(axis, c1, c2, amin, radius), + cls.circle_edges(axis, c1, c2, amax, radius), + sides)) + + @classmethod + def tube_bore_edges(cls, axis: int, c1: float, c2: float, amin: float, + amax: float, radius: float) -> Float64Points: + """The bore's two end circles. No longitudinals: inside the outer + wireframe they read as clutter, not as depth.""" + return np.vstack((cls.circle_edges(axis, c1, c2, amin, radius), + cls.circle_edges(axis, c1, c2, amax, radius))) + + @classmethod + def circle_edges(cls, axis: int, c1: float, c2: float, a: float, + radius: float) -> Float64Points: + """One closed circle perpendicular to ``axis``, as 2N endpoints.""" + i1, i2 = [i for i in (X, Y, Z) if i != axis] + theta = np.linspace(0.0, 2.0 * math.pi, cls.CIRCLE_SEGMENTS, + endpoint=False) + ring = np.empty((cls.CIRCLE_SEGMENTS, 3), dtype=np.float64) + ring[:, axis] = a + ring[:, i1] = c1 + radius * np.cos(theta) + ring[:, i2] = c2 + radius * np.sin(theta) + edges = np.empty((2 * cls.CIRCLE_SEGMENTS, 3), dtype=np.float64) + edges[0::2] = ring + edges[1::2] = np.roll(ring, -1, axis=0) + return edges + + # -- placement --------------------------------------------------------- + + @staticmethod + def _to_machine(points: Float64Points, canon: Any) -> Float64Points: + """Program coordinates to absolute machine coordinates. + + The offsets are applied in exactly the order ``_batch_run`` applies + them to a move endpoint - g92, then the XY rotation, then g5x - so a + workpiece corner and a move that touches it land on the same point, + bit for bit. There is no ``FRAME=`` key and there will not be one: the + frame is whatever was active at the comment, which is why a post + processor must emit it after its WCS statement. + + Tool offsets are deliberately absent. They move the *tool tip path*, + and would place the stock somewhere it is not. + """ + points = np.asarray(points, dtype=np.float64) + ( + canon.g92_offset_x, canon.g92_offset_y, canon.g92_offset_z) + if canon.rotation_xy: + rotx = (points[:, 0] * canon.rotation_cos + - points[:, 1] * canon.rotation_sin) + points[:, 1] = (points[:, 0] * canon.rotation_sin + + points[:, 1] * canon.rotation_cos) + points[:, 0] = rotx + points += (canon.g5x_offset_x, canon.g5x_offset_y, canon.g5x_offset_z) + return points + + @staticmethod + def _to_display(machine_points: Float64Points, + canon: Any) -> Float64Points: + """Machine coordinates to display coordinates, per the GEOMETRY string. + + Runs over 9-DOF points with the rotary columns zero, which leaves the + A/B/C branches identity, so any GEOMETRY string is safe here. + """ + pts9 = np.zeros((len(machine_points), 9), dtype=np.float64) + pts9[:, 0:3] = machine_points + program = canon.program_geometry + return glcanon_bake.transform_points(pts9, program.geometry, + program.ro) + + +class WorkpiecePart(Part): + """Wireframe stock outlines declared by ``(WORKPIECE,...)`` comments. + + Drawn translucent under the baseline blend state, which is already what + the scene sets up: the stock frames the toolpath rather than competing + with it, and its far edges have to stay readable as background. + """ + + def draw(self, ctx: FrameContext) -> None: + # A host that overrode the colour table before these keys existed has + # no 'workpiece' entry, and a KeyError here would take the whole frame + # down; getattr on the canon for the same reason - a GUI's canon + # subclass may predate the attribute. + color = ctx.colors.get('workpiece', WORKPIECE_COLOR) + alpha = ctx.colors.get('workpiece_alpha', WORKPIECE_ALPHA) + for workpiece in getattr(ctx.canon, 'workpieces', ()): + ctx.prim.draw_lines(ctx, workpiece.points, color, alpha) + + class PreviewScene(Scene): """The preview's part order, and the gates deciding what participates. @@ -2076,6 +2503,7 @@ def __init__(self) -> None: self.relative_coords = RelativeCoordPart() self.limits_box = LimitsBoxPart() self.backplot = BackplotPart() + self.workpiece = WorkpiecePart() self.tool = ToolPart() self.overlay = OverlayPart() super().__init__([ @@ -2090,6 +2518,8 @@ def __init__(self) -> None: or ctx.show_program), (self.limits_box, lambda ctx: ctx.show_limits), (self.backplot, lambda ctx: ctx.show_live_plot), + (self.workpiece, lambda ctx: ctx.show_workpiece + and ctx.canon is not None), (self.tool, lambda ctx: ctx.show_tool), (self.overlay, lambda ctx: ctx.enable_dro), ]) diff --git a/src/emc/usr_intf/gremlin/gremlin.py b/src/emc/usr_intf/gremlin/gremlin.py index 73d502fb68c..9e620554b75 100755 --- a/src/emc/usr_intf/gremlin/gremlin.py +++ b/src/emc/usr_intf/gremlin/gremlin.py @@ -268,6 +268,7 @@ def C(s): self.use_relative = True self.show_tool = True self.show_dtg = True + self.show_workpiece = True self.grid_size = 0.0 self.lathe_option = self.inifile.getbool("DISPLAY", "LATHE", fallback=False) self.foam_option = self.inifile.getbool("DISPLAY", "FOAM", fallback=False) @@ -659,6 +660,7 @@ def get_font_info(self): return self.font_charwidth, self.font_linespace, self.font_base def get_show_offsets(self): return self.show_offsets + def get_show_workpiece(self): return self.show_workpiece def select_prime(self, x, y): self.select_primed = x, y diff --git a/src/emc/usr_intf/gremlin/qt5_graphics.py b/src/emc/usr_intf/gremlin/qt5_graphics.py index fa07710bd27..b32debfd136 100644 --- a/src/emc/usr_intf/gremlin/qt5_graphics.py +++ b/src/emc/usr_intf/gremlin/qt5_graphics.py @@ -340,6 +340,7 @@ def C(s): self.show_tool = True self.show_lathe_radius = False self.show_dtg = True + self.show_workpiece = True self.grid_size = 0.0 self.lathe_option = self.inifile.getbool("DISPLAY", "LATHE", fallback=False) @@ -649,6 +650,7 @@ def get_show_tool(self): return self.show_tool def get_show_distance_to_go(self): return self.show_dtg def get_grid_size(self): return self.grid_size def get_show_offsets(self): return self.show_offsets + def get_show_workpiece(self): return self.show_workpiece def getEnableDRO(self): return self.enable_dro def get_view(self): view_dict = {'x':0, 'y':1, 'y2':1, 'z':2, 'z2':2, 'p':3} diff --git a/tests/glcanon/test.sh b/tests/glcanon/test.sh index b144c7d724c..2a33658aa7b 100755 --- a/tests/glcanon/test.sh +++ b/tests/glcanon/test.sh @@ -2,11 +2,21 @@ # Unit tests for the OpenGL 3.3 core / GLES 3.1 preview renderer shared by # AXIS, the GTK screens (Gremlin/gmoccapy/gscreen/hal_gremlin) and QtVCP. # -# Both are GL-free: they exercise the backplot palette and the explicit camera -# matrices as plain numpy, so they need neither a GPU nor an X display. +# None of them needs a GPU or an X display. The first two are GL-free +# outright - they load rs274.glcanon_bake by path and exercise it as plain +# numpy. The third imports rs274.glcanon, which pulls PyOpenGL at import time +# (it never calls into it here), and PyOpenGL is not installed in a headless +# build - so that one is conditional, the same rule tests/pyvcp/skip applies +# to a whole directory. set -e python3 test_backplot_palette.py >&2 python3 test_camera_matrices.py >&2 +if python3 -c 'import OpenGL' 2>/dev/null; then + python3 test_workpiece.py >&2 +else + echo "skip: test_workpiece.py needs PyOpenGL (headless build)" >&2 +fi + echo ok diff --git a/tests/glcanon/test_workpiece.py b/tests/glcanon/test_workpiece.py new file mode 100644 index 00000000000..dbdb8dbc76c --- /dev/null +++ b/tests/glcanon/test_workpiece.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""The ``(WORKPIECE,...)`` comment: what it parses to, and what it refuses. + +The parser is the whole feature - by the time anything draws, a workpiece is +just a list of endpoints, so everything that can be wrong about it is wrong +here: the frame the corners were placed in, the units they were declared in, +and whether a comment a post processor got slightly wrong takes the parse down +with it. + +Needs the RIP environment (rs274 pulls the compiled gcode extension) but no +display and no GL context: nothing below calls into OpenGL. + + . scripts/rip-environment && runtests tests/glcanon +""" +import math +import unittest + +import numpy as np + +import rs274.glcanon as glcanon +from rs274 import glcanon_scene + + +class StateStub: + """Just the modal g-code list the unit rule reads off the interp state.""" + + def __init__(self, gcodes=()): + self.gcodes = gcodes + + +def make_canon(gcodes=()): + canon = glcanon.GLCanon(colors={}, geometry="XYZ") + canon.state = StateStub(gcodes) + return canon + + +def parse(comment, **kw): + """The workpieces one comment leaves on a fresh canon.""" + canon = make_canon(**kw) + canon.comment(comment) + return canon.workpieces + + +def segments(points): + """The endpoint pairs as an order-independent set of unordered edges.""" + pts = [tuple(round(v, 9) for v in p) for p in points] + return {frozenset((pts[i], pts[i + 1])) for i in range(0, len(pts), 2)} + + +BOX = "WORKPIECE,BOX,XMIN=0,YMIN=0,ZMIN=-4,XMAX=10,YMAX=20,ZMAX=0" + + +class WorkpieceParseTest(unittest.TestCase): + def test_box_edges(self): + wp, = parse(BOX) + self.assertEqual(wp.shape, 'BOX') + self.assertEqual(len(wp.points), 24) + corners = [(x, y, z) for x in (0.0, 10.0) for y in (0.0, 20.0) + for z in (-4.0, 0.0)] + expected = set() + for a in corners: + for b in corners: + # An edge joins two corners differing in exactly one axis. + if sum(p != q for p, q in zip(a, b)) == 1: + expected.add(frozenset((a, b))) + self.assertEqual(len(expected), 12) + self.assertEqual(segments(wp.points), expected) + + def test_cylinder_about_z_by_default(self): + wp, = parse("WORKPIECE,CYLINDER,X=5,Y=-5,ZMIN=-40,ZMAX=0,DIAMETER=80") + self.assertEqual(wp.shape, 'CYLINDER') + # two end circles plus the four longitudinals + self.assertEqual(len(wp.points), 2 * 72 + 8) + radii = np.hypot(wp.points[:, 0] - 5.0, wp.points[:, 1] + 5.0) + np.testing.assert_allclose(radii, 40.0) + self.assertEqual(set(np.round(wp.points[:, 2], 9)), {-40.0, 0.0}) + + def test_cylinder_about_x(self): + wp, = parse("WORKPIECE,CYLINDER,AXIS=X,Y=0,Z=1,XMIN=0,XMAX=10," + "DIAMETER=4") + self.assertEqual(set(np.round(wp.points[:, 0], 9)), {0.0, 10.0}) + np.testing.assert_allclose( + np.hypot(wp.points[:, 1], wp.points[:, 2] - 1.0), 2.0) + + def test_tube_adds_bore_circles(self): + wp, = parse("WORKPIECE,TUBE,ZMIN=-10,ZMAX=0,DIAMETER=80," + "INNER_DIAMETER=40") + self.assertEqual(wp.shape, 'TUBE') + # the cylinder, plus one bore circle per end + self.assertEqual(len(wp.points), 2 * 72 + 8 + 2 * 72) + radii = set(np.round(np.hypot(wp.points[:, 0], wp.points[:, 1]), 6)) + self.assertEqual(radii, {40.0, 20.0}) + # a bore that is not inside the outer wall is not a tube + self.assertEqual(parse("WORKPIECE,TUBE,ZMIN=-10,ZMAX=0,DIAMETER=40," + "INNER_DIAMETER=40"), []) + + def test_machine_points_are_the_measurable_ones(self): + """machine_points is the frame a caller measures in; points is the + preview's, and GEOMETRY is the only thing between them.""" + # plain XYZ mill: the two frames are the same numbers + wp, = parse(BOX) + np.testing.assert_array_equal(wp.machine_points, wp.points) + self.assertEqual(wp.machine_extents, + ((0.0, 0.0, -4.0), (10.0, 20.0, 0.0))) + + # a GEOMETRY that negates X moves the drawn outline and leaves the + # machine one alone + canon = glcanon.GLCanon(colors={}, geometry="-XYZ") + canon.state = StateStub(()) + canon.configure_program_geometry("-XYZ", canon.program_geometry.ro, + False) + canon.comment(BOX) + wp, = canon.workpieces + self.assertEqual(wp.machine_extents, + ((0.0, 0.0, -4.0), (10.0, 20.0, 0.0))) + self.assertEqual(wp.extents, ((-10.0, 0.0, -4.0), (0.0, 20.0, 0.0))) + + def test_offsets_applied_in_move_order(self): + """A corner lands where the same point pushed through the move + pipeline lands - g92, then the XY rotation, then g5x, exact.""" + canon = make_canon() + canon.g92_offset_x, canon.g92_offset_y, canon.g92_offset_z = 1.5, -2.5, 3.0 + canon.rotation_xy = 30.0 + canon.rotation_cos = math.cos(math.radians(30.0)) + canon.rotation_sin = math.sin(math.radians(30.0)) + canon.g5x_offset_x, canon.g5x_offset_y, canon.g5x_offset_z = 10.0, 20.0, 30.0 + canon.comment(BOX) + wp, = canon.workpieces + + x = 0.0 + canon.g92_offset_x + y = 0.0 + canon.g92_offset_y + z = -4.0 + canon.g92_offset_z + rx = x * canon.rotation_cos - y * canon.rotation_sin + ry = x * canon.rotation_sin + y * canon.rotation_cos + self.assertEqual(tuple(wp.machine_points[0]), + (rx + canon.g5x_offset_x, + ry + canon.g5x_offset_y, + z + canon.g5x_offset_z)) + + def test_units(self): + """The canon counts in internal units - inches - on any machine, so + the only question is what the program's numbers meant.""" + # explicit UNITS wins over the modal state, both ways + wp, = parse(BOX + ",UNITS=MM", gcodes=(200,)) + self.assertAlmostEqual(wp.points[:, 0].max(), 10.0 / 25.4) + wp, = parse(BOX + ",UNITS=INCH", gcodes=(210,)) + self.assertAlmostEqual(wp.points[:, 0].max(), 10.0) + # no UNITS: the modal G21/G20 state decides + wp, = parse(BOX, gcodes=(210,)) + self.assertAlmostEqual(wp.points[:, 0].max(), 10.0 / 25.4) + wp, = parse(BOX, gcodes=(200,)) + self.assertAlmostEqual(wp.points[:, 0].max(), 10.0) + # an unusable UNITS value falls back to the modal state + wp, = parse(BOX + ",UNITS=FURLONG", gcodes=(210,)) + self.assertAlmostEqual(wp.points[:, 0].max(), 10.0 / 25.4) + + def test_malformed_never_breaks_the_parse(self): + canon = make_canon() + for bad in ("WORKPIECE,BOX,XMIN=0,YMIN=0,ZMIN=-4,XMAX=10,YMAX=20", + "WORKPIECE,BOX,XMIN=0,YMIN=0,ZMIN=-4,XMAX=10,YMAX=20," + "ZMAX=twelve", + "WORKPIECE,BOX,XMIN=10,YMIN=0,ZMIN=-4,XMAX=0,YMAX=20," + "ZMAX=0", + "WORKPIECE,SPHERE,DIAMETER=10", + "WORKPIECE,CYLINDER,AXIS=Q,ZMIN=0,ZMAX=1,DIAMETER=10", + "WORKPIECE,BOX,NONSENSE"): + canon.comment(bad) + self.assertEqual(canon.workpieces, []) + # unknown keys are forward compatibility, not an error + wp, = parse(BOX + ",FUTURE=7,UNITS=FURLONG") + self.assertEqual(len(wp.points), 24) + # and the legacy comments still work afterwards + canon.comment("AXIS,hide") + self.assertEqual(canon.suppress, 1) + canon.comment("AXIS,show") + self.assertEqual(canon.suppress, 0) + + def test_params_record_what_was_declared(self): + """What a GUI reads back: the declared keys, defaults filled in, in + canon units - not the wireframe.""" + canon = make_canon() + canon.lineno = 12 + canon.comment(BOX) + wp, = canon.workpieces + self.assertEqual(wp.params, {'XMIN': 0.0, 'YMIN': 0.0, 'ZMIN': -4.0, + 'XMAX': 10.0, 'YMAX': 20.0, 'ZMAX': 0.0}) + self.assertEqual(wp.lineno, 12) + + # optional keys are present at their default, and the unit conversion + # has already been applied + wp, = parse("WORKPIECE,TUBE,ZMIN=-10,ZMAX=0,DIAMETER=80," + "INNER_DIAMETER=40,UNITS=MM") + self.assertEqual(wp.params['AXIS'], 'Z') + self.assertEqual(wp.params['X'], 0.0) + self.assertEqual(wp.params['Y'], 0.0) + self.assertAlmostEqual(wp.params['DIAMETER'], 80.0 / 25.4) + self.assertAlmostEqual(wp.params['INNER_DIAMETER'], 40.0 / 25.4) + + # an unknown key stays out of params, so a reader cannot come to + # depend on one this version ignored + wp, = parse(BOX + ",FUTURE=7") + self.assertNotIn('FUTURE', wp.params) + + def test_comments_are_additive(self): + canon = make_canon() + canon.comment(BOX) + canon.comment("WORKPIECE,CYLINDER,ZMIN=0,ZMAX=1,DIAMETER=2") + self.assertEqual([wp.shape for wp in canon.workpieces], + ['BOX', 'CYLINDER']) + + +class CtxStub: + """What WorkpiecePart is allowed to read, and a record of what it drew.""" + + class Prim: + def __init__(self): + self.calls = [] + + def draw_lines(self, ctx, points, color, alpha=1.0): + self.calls.append((len(points), tuple(color), alpha)) + + def __init__(self, canon): + self.canon = canon + self.colors = glcanon.GlCanonDraw.colors + self.prim = self.Prim() + + +class WorkpiecePartTest(unittest.TestCase): + def test_draws_one_call_per_workpiece(self): + canon = make_canon() + canon.comment(BOX) + canon.comment("WORKPIECE,CYLINDER,ZMIN=0,ZMAX=1,DIAMETER=2") + ctx = CtxStub(canon) + glcanon_scene.WorkpiecePart().draw(ctx) + self.assertEqual([n for n, _c, _a in ctx.prim.calls], [24, 152]) + self.assertEqual({(c, a) for _n, c, a in ctx.prim.calls}, + {(tuple(glcanon_scene.WORKPIECE_COLOR), + glcanon_scene.WORKPIECE_ALPHA)}) + + def test_draws_nothing_without_the_attribute(self): + for canon in (None, object()): + ctx = CtxStub(canon) + glcanon_scene.WorkpiecePart().draw(ctx) + self.assertEqual(ctx.prim.calls, []) + + def test_host_without_the_colour_entries_still_draws(self): + canon = make_canon() + canon.comment(BOX) + ctx = CtxStub(canon) + ctx.colors = {} + glcanon_scene.WorkpiecePart().draw(ctx) + self.assertEqual(len(ctx.prim.calls), 1) + + +if __name__ == '__main__': + unittest.main()