# This is a component of AXIS, a front-end for emc # Copyright 2004, 2005, 2006 Jeff Epler # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from rs274 import Translated, ArcsToSegmentsMixin from rs274 import glcanon_gl, glcanon_bake, glcanon_scene # Bound locally: the canon tags every move with one of these, so the lookup is # on the parse hot path. from rs274.glcanon_bake import CAT_TRAVERSE, CAT_FEED, CAT_ARC from OpenGL.GL import * from OpenGL.GLU import * import logging import math import hershey import linuxcnc import gcode import numpy as np import os import warnings from functools import reduce log = logging.getLogger(__name__) # Axis views, viewport coordinates and the DRO home/limit icons now live with # the scene that draws from them; they are re-exported here because they have # long been part of this module's surface. from rs274.glcanon_scene import (X, Y, Z, A, B, C, U, V, W, R, # noqa: F401 VX, VY, VZ, VP, minmax, allhomedicon, somelimiticon, homeicon, limiticon) # Moves the canon lets pile up before filling them into the program record. # Checked once per source line, not once per move, so it costs nothing on the # move path; it bounds the fill's peak working set rather than the batch size # exactly, which is all it is for. FILL_BATCH = 4096 def _removed_attribute(name, replacement): """A read-only property that raises, naming what replaced it. ``traverse``/``feed``/``arcfeed``/``moves``/``move_cats`` and ``preview_zero_rxy`` are undocumented but twenty years old, and a third-party GUI or a user's canon subclass may still read one. Silently returning nothing - or a bare ``AttributeError`` - leaves such code guessing; this is a signpost, not a compatibility shim, and it must not appear to work. """ def getter(self): raise AttributeError( "GLCanon.%s was removed (see retire-canon-move-lists); %s" % (name, replacement)) return property(getter) class GLCanon(Translated, ArcsToSegmentsMixin): lineno = -1 # See _removed_attribute: these were the per-move category lists, # emission-order list and un-rotated preview copy. The program record # (self.program_geometry) replaces all of them. traverse = _removed_attribute( "traverse", "read program_geometry (positions()/lines/kinds) or g0_length") feed = _removed_attribute( "feed", "read program_geometry (positions()/lines/kinds) or g1_length/run_time()") arcfeed = _removed_attribute( "arcfeed", "read program_geometry (positions()/lines/kinds) or g1_length/run_time()") moves = _removed_attribute( "moves", "read program_geometry - it is the program record, in emission order") move_cats = _removed_attribute( "move_cats", "read program_geometry.kinds") preview_zero_rxy = _removed_attribute( "preview_zero_rxy", "read program_geometry.extents_zero_rxy / extents_notool_zero_rxy, " "accumulated during the fill") def __init__(self, colors, geometry, is_foam=0, foam_w=1.5, foam_z=0.0): # dwell list - [line number, color, pos x, pos y, pos z, plane] self.dwells = [] self.tool_list = [] # The program record. Constructed here rather than lazily so that # "the canon always has one" is true of a canon nothing ever parsed # into, and readable the moment gcode.parse returns - which is when # load_preview wants the extents, long before any GL context exists. # Named program_geometry, not geometry: that name is already this # class's GEOMETRY *string*, which the fill reads. self._program_geometry = glcanon_bake.ProgramGeometry( geometry=geometry, is_foam=bool(is_foam)) # Moves recorded since the last flush: a reusable float64 chunk # (lineno, p1, p2, feedrate, offset per row - see STAGING_ROW_WIDTH) # that rotate_and_translate's result is written straight into, plus a # parallel byte per move naming its category. Neither is retained # once flushed. Cost is bounded by FILL_BATCH, never by program # length - and normally by one allocation for the file, since the # array is reused in place and only grows if a single source line # emits more moves than fit before the next flush check. self._staging = np.empty( (FILL_BATCH, glcanon_bake.STAGING_ROW_WIDTH), dtype=np.float64) self._staged = 0 self._pending_kinds = bytearray() self.choice = None self.feedrate = 1 self.lo = (0,) * 9 self.first_move = True self.geometry = geometry # min and max extents - the largest bounding box around the currently displayed preview # bounding box is parallel to the machine axes self.min_extents = [9e99,9e99,9e99] self.max_extents = [-9e99,-9e99,-9e99] self.min_extents_notool = [9e99,9e99,9e99] self.max_extents_notool = [-9e99,-9e99,-9e99] # min and max extents at zero rotation - the largest bounding box around the preview # after unrotating it by the amount of current g5x offset XY rotation # bounding box is parallel to the machine axes. If the box is rotated by the g5x offset XY rotation amount # it can be used to give a more accurate visual of where the cut will occur self.min_extents_zero_rxy = [9e99,9e99,9e99] self.max_extents_zero_rxy = [-9e99,-9e99,-9e99] self.min_extents_notool_zero_rxy = [9e99,9e99,9e99] self.max_extents_notool_zero_rxy = [-9e99,-9e99,-9e99] self.colors = colors # Set if the parse was aborted, so the extents above are only partial. self.preview_incomplete = False self.in_arc = 0 self.xo = self.yo = self.zo = self.ao = self.bo = self.co = self.uo = self.vo = self.wo = 0 self.dwell_time = 0 self.suppress = 0 self.g92_offset_x = 0.0 self.g92_offset_y = 0.0 self.g92_offset_z = 0.0 self.g92_offset_a = 0.0 self.g92_offset_b = 0.0 self.g92_offset_c = 0.0 self.g92_offset_u = 0.0 self.g92_offset_v = 0.0 self.g92_offset_w = 0.0 self.g5x_index = 1 self.g5x_offset_x = 0.0 self.g5x_offset_y = 0.0 self.g5x_offset_z = 0.0 self.g5x_offset_a = 0.0 self.g5x_offset_b = 0.0 self.g5x_offset_c = 0.0 self.g5x_offset_u = 0.0 self.g5x_offset_v = 0.0 self.g5x_offset_w = 0.0 self.is_foam = is_foam self.foam_z = foam_z self.foam_w = foam_w self.notify = 0 self.notify_message = "" self.highlight_line = None def comment(self, arg): if arg.startswith("AXIS,") or arg.startswith("PREVIEW,"): parts = arg.split(",") command = parts[1] if command == "stop": raise KeyboardInterrupt if command == "hide": self.suppress += 1 if command == "show": self.suppress -= 1 if command == "XY_Z_POS": if len(parts) > 2 : try: self.foam_z = float(parts[2]) if 210 in self.state.gcodes: self.foam_z = self.foam_z / 25.4 except: self.foam_z = 5.0/25.4 if command == "UV_Z_POS": if len(parts) > 2 : try: self.foam_w = float(parts[2]) if 210 in self.state.gcodes: self.foam_w = self.foam_w / 25.4 except: self.foam_w = 30.0 if command == "notify": self.notify = self.notify + 1 self.notify_message = "(AXIS,notify):" + str(self.notify) if len(parts) > 2: if len(parts[2]): self.notify_message = parts[2] def message(self, message): pass def check_abort(self): pass def next_line(self, st): self.state = st self.lineno = self.state.sequence_number if self._staged >= FILL_BATCH: self._flush_moves() # -- the program record ------------------------------------------------ @property def program_geometry(self): """The program record, with every move reported so far filled in. A property rather than a plain attribute so that reading it is always reading a complete record: moves accumulate between flushes, and a reader that took the object during the parse would otherwise see a prefix of the program and no way to know it. """ self._flush_moves() return self._program_geometry def configure_program_geometry(self, geometry, ro, is_foam): """Choose the transform the fill will apply, and clear what it filled. Called by the widget when the canon is set, i.e. just before the parse. The points are converted once, on the way in, so this is not something that can be changed afterwards - which is why it discards whatever was already filled rather than leaving a half-transformed array behind. Deliberately does NOT write back to ``self.geometry`` or ``self.is_foam``. Those are the canon's own, set by whoever constructed it, and gremlin's does not set ``is_foam`` at all while its widget may report foam - so adopting the widget's answer here would quietly change which programs get the foam Z override in calc_extents. The drawn planes follow the widget, exactly as the pre-change bake did; the extents rule follows the canon, exactly as it did. """ self._staged = 0 self._pending_kinds = bytearray() self._program_geometry.configure(geometry=geometry, ro=ro, is_foam=is_foam) def _reserve_staging(self, extra): """Grow the staging chunk to hold ``extra`` more rows, by doubling. Normally a no-op: the chunk is reused across flushes at its initial ``FILL_BATCH`` capacity. It only grows for the rare source line that emits more moves than that in one callback - a large arc's segments, say - since the flush check in ``next_line`` runs once per source line, not once per move. """ need = self._staged + extra cap = len(self._staging) if need <= cap: return new_cap = max(FILL_BATCH, cap * 2) while new_cap < need: new_cap *= 2 grown = np.empty((new_cap, glcanon_bake.STAGING_ROW_WIDTH), dtype=np.float64) grown[:self._staged] = self._staging[:self._staged] self._staging = grown def _stage_move(self, lineno, p1, p2, feedrate, offset, kind): """Write one move straight into the staging chunk. No tuple, no list append beyond the one-byte kind.""" self._reserve_staging(1) row = self._staging[self._staged] row[glcanon_bake.STAGE_LINE] = lineno row[glcanon_bake.STAGE_P1] = p1 row[glcanon_bake.STAGE_P2] = p2 row[glcanon_bake.STAGE_FEEDRATE] = feedrate row[glcanon_bake.STAGE_OFFSET] = offset self._pending_kinds.append(kind) self._staged += 1 def _flush_moves(self): """Fill everything recorded since the last flush. The rotation and g5x origin are passed per batch because they are per batch: they are what the rotation-removed extents are accumulated with, and a program that rotates mid-file gets each move's own. That is why set_xy_rotation and set_g5x_offset flush before changing them. """ n = self._staged if n == 0: return chunk = self._staging[:n] kinds = self._pending_kinds # Cleared before the call, not after: add_moves_raw is where a # malformed move would raise, and a non-empty pending count would # re-fill the same rows into the array on the next flush. The chunk # itself is not reallocated - add_moves_raw reads it before this # method returns, and self._staged = 0 alone is what lets the next # _stage_move start overwriting it. self._staged = 0 self._pending_kinds = bytearray() self._program_geometry.add_moves_raw( chunk, kinds, self.rotation_xy, (self.g5x_offset_x, self.g5x_offset_y)) def set_xy_rotation(self, theta): self._flush_moves() Translated.set_xy_rotation(self, theta) def set_g5x_offset(self, *args, **kw): self._flush_moves() Translated.set_g5x_offset(self, *args, **kw) def calc_extents(self): # A delegation onto the values accumulated during the fill, plus the # two rules that have always lived here and are not properties of the # move data at all: the blank-program case and the foam Z override. # # gcode.calc_extents (C) stays in emcmodule.cc as public module API, # but nothing here calls it any more - see # tests/gcode-bake/test_extents_oracle.py, which now pins the # accumulated values against fixtures recorded from that oracle # rather than calling it live. geometry = self.program_geometry # In the event of a "blank" gcode file (M2 only for example) this sets each of the extents to [0,0,0] # to prevent passing the very large [9e99,9e99,9e99] values and populating the gcode properties with # unusably large values. Some screens use the extents information to set the view distance so 0 values are preferred. if geometry.is_empty: self.min_extents = \ self.max_extents = \ self.min_extents_notool = \ self.max_extents_notool = \ self.min_extents_zero_rxy = \ self.max_extents_zero_rxy = \ self.min_extents_notool_zero_rxy = \ self.max_extents_notool_zero_rxy = [0,0,0] return # Plain floats, not numpy scalars: these eight are read outside # rs274 - by the AXIS, gremlin and QtVCP properties dialogs - and have # always been lists of Python floats, which is what the C returned. def pair(extents): return [[float(v) for v in vector] for vector in extents] (self.min_extents, self.max_extents) = pair(geometry.extents) (self.min_extents_notool, self.max_extents_notool) = pair(geometry.extents_notool) (self.min_extents_zero_rxy, self.max_extents_zero_rxy) = pair(geometry.extents_zero_rxy) (self.min_extents_notool_zero_rxy, self.max_extents_notool_zero_rxy) = pair( geometry.extents_notool_zero_rxy) if self.is_foam: min_z = min(self.foam_z, self.foam_w) max_z = max(self.foam_z, self.foam_w) self.min_extents = self.min_extents[0], self.min_extents[1], min_z self.max_extents = self.max_extents[0], self.max_extents[1], max_z self.min_extents_notool = \ self.min_extents_notool[0], self.min_extents_notool[1], min_z self.max_extents_notool = \ self.max_extents_notool[0], self.max_extents_notool[1], max_z @property def g0_length(self): """Total rapid (traverse) path length, accumulated during the fill. Replaces ``sum(dist(l[1][:3], l[2][:3]) for l in canon.traverse)``, read by the gremlin/AXIS/qt5_graphics properties dialogs. """ return self.program_geometry.rapid_length @property def g1_length(self): """Total cutting (feed + arc) path length, accumulated during the fill. Replaces the equivalent summation over ``canon.feed``/``canon.arcfeed``. """ return self.program_geometry.cutting_length def run_time(self, max_feed_rate): """Cutting + rapid time at ``max_feed_rate``, plus ``self.dwell_time``. ``max_feed_rate`` is the machine's ``max_speed``, known only to the GUI that built this canon - not to the parse - which is why this is a method rather than a plain attribute; see the design's discussion of why ``min(max_feed_rate, feed)`` cannot be pre-summed into one scalar. Replaces the ``gt`` summation the properties dialogs used to run over ``traverse``/``feed``/``arcfeed``. """ geometry = self.program_geometry return (geometry.cutting_time(max_feed_rate) + geometry.rapid_length / max_feed_rate + self.dwell_time) def tool_offset(self, xo, yo, zo, ao, bo, co, uo, vo, wo): self.first_move = True x, y, z, a, b, c, u, v, w = self.lo self.lo = (x - xo + self.xo, y - yo + self.yo, z - zo + self.zo, a - ao + self.ao, b - bo + self.bo, c - co + self.co, u - uo + self.uo, v - vo + self.vo, w - wo + self.wo) self.xo = xo self.yo = yo self.zo = zo self.ao = ao self.bo = bo self.co = co self.uo = uo self.vo = vo self.wo = wo def set_spindle_rate(self, arg): pass def set_feed_rate(self, arg): self.feedrate = arg / 60. def select_plane(self, arg): pass def change_tool(self, arg): self.first_move = True try: self.tool_list.append(arg) except Exception: log.exception("could not record tool change to %r", arg) # Flush first: the record vertex has to land between the move before # the change and the move after it, and the moves before it are still # sitting in the pending buffer. The jump that follows is not recorded # here - first_move means the next move starts wherever the tool # ended up, so the fill sees the discontinuity itself. self._flush_moves() self._program_geometry.mark_toolchange(self.lineno, self.lo, arg) def straight_traverse(self, x,y,z, a,b,c, u,v,w): if self.suppress > 0: return l = self.rotate_and_translate(x,y,z,a,b,c,u,v,w) if not self.first_move: self._stage_move(self.lineno, self.lo, l, 0.0, (self.xo, self.yo, self.zo), CAT_TRAVERSE) self.lo = l def rigid_tap(self, x, y, z): if self.suppress > 0: return self.first_move = False l = self.rotate_and_translate(x,y,z,0,0,0,0,0,0)[:3] l += (self.lo[3], self.lo[4], self.lo[5], self.lo[6], self.lo[7], self.lo[8]) offset = (self.xo, self.yo, self.zo) # self.dwells.append((self.lineno, self.colors['dwell'], x + self.offset_x, y + self.offset_y, z + self.offset_z, 0)) self._reserve_staging(2) self._stage_move(self.lineno, self.lo, l, self.feedrate, offset, CAT_FEED) self._stage_move(self.lineno, l, self.lo, self.feedrate, offset, CAT_FEED) def arc_feed(self, *args): if self.suppress > 0: return self.first_move = False self.in_arc = True try: ArcsToSegmentsMixin.arc_feed(self, *args) finally: self.in_arc = False def straight_arcsegments(self, segs): self.first_move = False lo = self.lo lineno = self.lineno feedrate = self.feedrate to = (self.xo, self.yo, self.zo) # Reserved once for the whole arc, not once per segment: an arc can # subdivide into tens of segments in one call, all before next_line # next checks the flush threshold. self._reserve_staging(len(segs)) for l in segs: self._stage_move(lineno, lo, l, feedrate, to, CAT_ARC) lo = l self.lo = lo def straight_feed(self, x,y,z, a,b,c, u,v,w): if self.suppress > 0: return self.first_move = False l = self.rotate_and_translate(x,y,z,a,b,c,u,v,w) self._stage_move(self.lineno, self.lo, l, self.feedrate, (self.xo, self.yo, self.zo), CAT_FEED) self.lo = l def straight_probe(self, x,y,z, a,b,c, u,v,w): if self.suppress > 0: return self.first_move = False l = self.rotate_and_translate(x,y,z,a,b,c,u,v,w) self._stage_move(self.lineno, self.lo, l, self.feedrate, (self.xo, self.yo, self.zo), CAT_FEED) self.lo = l def user_defined_function(self, i, p, q): if self.suppress > 0: return color = self.colors['m1xx'] self._record_dwell(color) def dwell(self, arg): if self.suppress > 0: return self.dwell_time += arg color = self.colors['dwell'] self._record_dwell(color) def _record_dwell(self, color): """Append a dwell to ``self.dwells`` and to the program record. ``self.dwells`` keeps raw machine coordinates, because that is what it has always held; it is bounded by event count, not move count, so it is kept (unlike the per-move category lists). The record takes the 9-DOF point and transforms it, which is the marker position fix: the pre-change marker bake was handed the GEOMETRY string and the rotation offsets and applied neither. """ plane = int(self.state.plane/10-17) self.dwells.append((self.lineno, color, self.lo[0], self.lo[1], self.lo[2], plane)) self._flush_moves() self._program_geometry.mark_dwell(self.lineno, self.lo, color, plane) def highlight(self, lineno, geometry): # Return the centroid of the highlighted line's segments; the view # recentres on it. The highlight geometry itself is drawn by # glcanon_scene.HighlightPart, so no GL is emitted here. In # particular we must NOT call linuxcnc.line9() (as the legacy path did): # it emits immediate-mode glVertex, which is invalid in the 3.3 core # profile and leaves a GL_INVALID_OPERATION pending that later surfaces # on an unrelated glViewport. # # A mask on the array's line column, covering drawn segments and dwell # records in one expression, replacing four full-program Python loops. # The former loops collected BOTH endpoints of every matching segment, # so an interior point shared by two same-line segments counted twice; # reproduced here by weighting each vertex by the number of incident # same-line drawn segments, plus one for a dwell record of its own. geom = self.program_geometry n = len(geom) weight = np.zeros(n, dtype=np.float64) if n > 1: lines = geom.lines kinds = geom.kinds seg_match = ((lines[1:] == lineno) & (kinds[1:] <= glcanon_bake.LAST_DRAWN_KIND)) weight[:-1] += seg_match weight[1:] += seg_match weight += (lines == lineno) & (kinds == glcanon_bake.KIND_DWELL) total = weight.sum() if total > 0: pos = geom.positions() x = float((pos[:, 0] * weight).sum() / total) y = float((pos[:, 1] * weight).sum() / total) z = float((pos[:, 2] * weight).sum() / total) else: x = (self.min_extents[X] + self.max_extents[X])/2 y = (self.min_extents[Y] + self.max_extents[Y])/2 z = (self.min_extents[Z] + self.max_extents[Z])/2 return x, y, z def color_with_alpha(self, colorname): """Retired: set the fixed-function current colour. Does nothing. The renderer resolves per-kind colours in rs274.glcanon_bake and feeds them to the shaders, so there is no current colour left to set. Kept as an inert shim because it is a public method and out-of-tree screens may still call it, where it would otherwise raise GLError under a core context. """ global _warned_color_with_alpha if not _warned_color_with_alpha: _warned_color_with_alpha = True warnings.warn("GlCanonDraw.color_with_alpha() no longer draws; the " "renderer resolves per-kind colours in rs274.glcanon_bake", DeprecationWarning, stacklevel=2) def color(self, colorname): """Retired: set the fixed-function current colour. Does nothing. See color_with_alpha(). """ global _warned_color if not _warned_color: _warned_color = True warnings.warn("GlCanonDraw.color() no longer draws; the renderer " "resolves per-kind colours in rs274.glcanon_bake", DeprecationWarning, stacklevel=2) # Warn once per process per method. A module-level flag rather than the # warnings filter, so a GUI that has installed its own filter still gets # exactly one notice — and so these per-frame draw-path methods cost a branch # rather than the warning machinery on every call. _warned_color_with_alpha = False _warned_color = False def with_context(f): def inner(self, *args, **kw): self.activate() try: return f(self, *args, **kw) finally: self.deactivate() return inner def with_context_swap(f): def inner(self, *args, **kw): self.activate() try: return f(self, *args, **kw) finally: self.swapbuffers() self.deactivate() return inner class GlCanonDraw: colors = { 'traverse': (0.30, 0.50, 0.50), 'traverse_alpha': 1/3., 'traverse_xy': (0.30, 0.50, 0.50), 'traverse_alpha_xy': 1/3., 'traverse_uv': (0.30, 0.50, 0.50), 'traverse_alpha_uv': 1/3., 'backplotprobing_alpha': 0.75, 'backplotprobing': (0.63, 0.13, 0.94), 'backplottraverse': (0.30, 0.50, 0.50), 'label_ok': (1.00, 0.51, 0.53), 'backplotjog_alpha': 0.75, 'tool_diffuse': (0.60, 0.60, 0.60), 'backplotfeed': (0.75, 0.25, 0.25), 'back': (0.00, 0.00, 0.00), 'lathetool_alpha': 0.10, 'axis_x': (0.20, 1.00, 0.20), 'cone': (1.00, 1.00, 1.00), 'cone_xy': (0.00, 1.00, 0.00), 'cone_uv': (0.00, 0.00, 1.00), 'axis_z': (0.20, 0.20, 1.00), 'label_limit': (1.00, 0.21, 0.23), 'backplotjog': (1.00, 1.00, 0.00), 'selected': (0.00, 1.00, 1.00), 'lathetool': (0.80, 0.80, 0.80), 'dwell': (1.00, 0.50, 0.50), 'overlay_foreground': (1.00, 1.00, 1.00), 'overlay_background': (0.00, 0.00, 0.00), 'straight_feed': (1.00, 1.00, 1.00), 'straight_feed_alpha': 1/3., 'straight_feed_xy': (0.20, 1.00, 0.20), 'straight_feed_alpha_xy': 1/3., 'straight_feed_uv': (0.20, 0.20, 1.00), 'straight_feed_alpha_uv': 1/3., 'small_origin': (0.00, 1.00, 1.00), 'backplottoolchange_alpha': 0.25, 'backplottraverse_alpha': 0.25, 'overlay_alpha': 0.75, 'tool_ambient': (0.40, 0.40, 0.40), 'tool_alpha': 0.20, 'backplottoolchange': (1.00, 0.65, 0.00), 'backplotarc': (0.75, 0.25, 0.50), 'm1xx': (0.50, 0.50, 1.00), 'backplotfeed_alpha': 0.75, 'backplotarc_alpha': 0.75, 'arc_feed': (1.00, 1.00, 1.00), 'arc_feed_alpha': .5, 'arc_feed_xy': (0.20, 1.00, 0.20), 'arc_feed_alpha_xy': 1/3., 'arc_feed_uv': (0.20, 0.20, 1.00), 'arc_feed_alpha_uv': 1/3., 'axis_y': (1.00, 0.20, 0.20), 'grid': (0.15, 0.15, 0.15), 'limits': (1.0, 0.0, 0.0), } def __init__(self, s=None, lp=None, g=None): self.stat = s self.lp = lp self.canon = g self._renderer = None # rs274.glcanon_gl.GlCanonRenderer (lazy) # Explicit numpy model-view stack replacing the legacy GL matrix stack; # (re)seeded to the camera modelview at frame start. self._projection is # the frame's projection (glnav folds the eye translation into it). self.mv = glcanon_scene.MatrixStack() # Shared drawing services the scene parts call through ctx.prim, and # the ordered scene redraw() runs. self.prim = glcanon_scene.Primitives() self.scene = self.build_scene() # Click-to-select shares the program's baked geometry with the scene, # so what is pickable is exactly what is drawn. self.picker = glcanon_scene.Picker(self.scene.program.resource) self.select_buffer_size = 100 self.cached_tool = -1 self.initialised = 0 self.no_joint_display = False self.kinsmodule = "UNKNOWN" self.trajcoordinates = "unknown" self.dro_in = "% 9.4f" self.dro_mm = "% 9.3f" self.enable_dro = True self.cone_basesize = .5 self.show_small_origin = True self.foam_w_height = 1.5 self.foam_z_height = 0 self.hide_icons = False self.disable_cone_scaling = False self.view_tool_min_dia = 0.0 try: system_memory_bytes = os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES') except Exception: # 4 GB, as the message says. This used to read `= 4`, four bytes, # which drove max_file_size below one byte and so refused to preview # every non-empty file - on the one path where nothing else went # wrong enough to explain it. system_memory_bytes = 4 * 1024 ** 3 log.warning("unable to determine system memory, " "defaulting to 4 GB for the preview file size limit") system_memory_gb = system_memory_bytes / (1024 ** 3) # Set to -1 to disable the file size limit. # One megabyte per gigabyte of system memory, capped at 20MB. The # "or 1/4 of system memory" this comment used to claim is not what the # line computes and never was (CMorley noted the discrepancy in 2024); # the cap is left as it behaves rather than as it was described. self.max_file_size = min(system_memory_gb, 20) * 1024 * 1024 #: Set once per load in load_preview() when the file exceeds the size #: limit; _resolve_show_program() reads it instead of re-stat'ing the #: file on every frame. self.preview_too_large = False try: if os.environ["INI_FILE_NAME"]: self.inifile = linuxcnc.ini(os.environ["INI_FILE_NAME"]) if self.inifile.hasvariable("DISPLAY", "DRO_FORMAT_IN"): temp = self.inifile.find("DISPLAY", "DRO_FORMAT_IN") try: test = temp % 1.234 except: log.warning("invalid [DISPLAY] DRO_FORMAT_IN in INI " "file: %r", temp) else: self.dro_in = temp if self.inifile.hasvariable("DISPLAY", "DRO_FORMAT_MM"): temp = self.inifile.find("DISPLAY", "DRO_FORMAT_MM") try: test = temp % 1.234 except: log.warning("invalid [DISPLAY] DRO_FORMAT_MM in INI " "file: %r", temp) else: self.dro_mm = temp self.dro_in = temp self.foam_w_height = self.inifile.getreal("DISPLAY", "FOAM_W", fallback=1.5) self.foam_z_height = self.inifile.getreal("DISPLAY", "FOAM_Z", fallback=0) size = self.inifile.getreal("DISPLAY", "CONE_BASESIZE") if size is not None: self.set_cone_basesize(size) # set maximum file size before showing boundary box instead temp = self.inifile.getint("DISPLAY", "GRAPHICAL_MAX_FILE_SIZE") if not temp is None: self.max_file_size = temp * 1024 * 1024 self.disable_cone_scaling = self.inifile.getbool("DISPLAY", "DISABLE_CONE_SCALING", fallback=False) self.view_tool_min_dia = self.inifile.getreal("DISPLAY", "GCODE_VIEW_TOOL_MIN_DIA", fallback=0.0) except: # Probably started in an editor so no INI pass @property def show_overlay(self): """Whether the DRO backdrop is showing. The latch lives on the overlay part; kept as an attribute because the GTK/Qt widgets set it.""" return self.scene.overlay.backdrop @show_overlay.setter def show_overlay(self, value): self.scene.overlay.backdrop = bool(value) def set_cone_basesize(self, size): if size > 2 or size < .025: log.warning("invalid cone base size %r, resetting to 0.5", size) size = 0.5 self.cone_basesize = size self._redraw() def init_glcanondraw(self,trajcoordinates="XYZABCUVW",kinsmodule="trivkins",msg=""): self.trajcoordinates = trajcoordinates.upper().replace(" ","") self.kinsmodule = kinsmodule self.no_joint_display = self.stat.kinematics_type == linuxcnc.KINEMATICS_IDENTITY log.debug("init_glcanondraw %s coords=%s kinsmodule=%s no_joint_display=%d", msg, self.trajcoordinates, self.kinsmodule, self.no_joint_display) g = self.get_geometry().upper() linuxcnc.gui_respect_offsets(self.trajcoordinates,int('!' in g)) geometry_chars = "XYZABCUVW-!;" dupchars = []; badchars = [] for ch in g: if g.count(ch) >1: dupchars.append(ch) if not ch in geometry_chars: badchars.append(ch) if dupchars: log.warning("duplicate chars %s in geometry: %s", dupchars, g) if badchars: log.warning("unknown chars %s in geometry: %s", badchars, g) def realize(self): self.hershey = hershey.Hershey() self.prim.hershey = self.hershey glPixelStorei(GL_UNPACK_ALIGNMENT, 1) glEnable(GL_DEPTH_TEST) glDepthFunc(GL_LESS) self._log_gl_context() self.initialised = 1 def _log_gl_context(self): """Record which API and version the preview actually got, once. Two contexts are accepted - OpenGL 3.3 core and OpenGL ES 3.1 - and on a Raspberry Pi they are visually indistinguishable apart from line width, so a bug report has to be able to say which one ran without asking the reporter to install and run eglinfo. Not on the terminal, though: it says nothing the operator can act on, and at one line per widget per realize it is noise for everyone who is not chasing a rendering bug. Ask a reporter for GLCANON_DEBUG=1, which adds the full version/renderer/GLSL strings - the GLSL version is the part that distinguishes the two accepted contexts (GLSL 330 against GLSL ES 300) and the renderer string names the driver. """ caps = self.renderer.caps log.info("preview renderer: %s %s", "OpenGL ES" if caps.is_gles else "OpenGL", caps.describe()) if not log.isEnabledFor(logging.DEBUG): return def s(name): try: v = glGetString(name) return v.decode("ascii", "replace") if isinstance(v, bytes) else str(v) except Exception as e: return "<%s>" % e log.debug("glcanon GL context: version=%r renderer=%r glsl=%r", s(GL_VERSION), s(GL_RENDERER), s(GL_SHADING_LANGUAGE_VERSION)) def set_canon(self, canon): self.canon = canon self.canon.foam_z = self.foam_z_height self.canon.foam_w = self.foam_w_height # Configure the canon's program record with the two things only the # widget knows, and do it here because load_preview calls set_canon # immediately before gcode.parse - the last moment at which the # transform the fill will apply can still be chosen. # # Deliberate consequence: a later change to the g5x/g92 offsets that # rotation_offsets() reads no longer re-transforms the preview. It # never did in practice - nothing invalidates the program on an offset # change, only a reload does - so this makes the existing behaviour # explicit rather than changing it. canon.configure_program_geometry(self.get_geometry().upper(), self.rotation_offsets(), bool(self.is_foam())) self.scene.program.invalidate() # -- core-profile renderer plumbing ------------------------------------ def _ensure_renderer(self): if self._renderer is None: self._renderer = glcanon_gl.GlCanonRenderer() return self._renderer @property def renderer(self): """The GL renderer the parts draw through (created on first use).""" return self._ensure_renderer() # -- explicit model-view stack (replaces the legacy GL matrix stack) ---- # The stack itself lives in glcanon_scene.MatrixStack (self.mv), which the # scene parts use through the per-frame context with scoped pushes; these # stay as the shims the pre-scene call sites (and qt5_graphics) use. @property def _projection(self): return self.mv.projection @_projection.setter def _projection(self, matrix): self.mv.projection = np.asarray(matrix, dtype=np.float64) def _mv_reset(self, matrix): self.mv.reset(matrix) def _mv_top(self): return self.mv.top() def _mv_push(self): self.mv.push_unscoped() def _mv_pop(self): self.mv.pop_unscoped() def _mv_mult(self, matrix): self.mv.mult(matrix) def _mv_translate(self, x, y, z): self.mv.translate(x, y, z) def _mv_rotate(self, angle, x, y, z): self.mv.rotate(angle, x, y, z) def _mv_scale(self, x, y, z): self.mv.scale(x, y, z) def preview_mvp(self): """Model-view-projection for the camera (the frame's world transform).""" return self._preview_mvp() def _preview_mvp(self): """Model-view-projection for the current camera, as a numpy 4x4. Combines the explicit glnav projection (which folds in the eye translation) and modelview - the same matrices the legacy path loads onto the GL stack - so the shader path draws in the identical frame. """ w = self.winfo_width() h = self.winfo_height() return self.get_projection_matrix(w, h) @ self.get_modelview_matrix() def rotation_offsets(self): """glcanon_bake.RotationOffsets matching the C gui_respect_offsets state.""" g = self.get_geometry().upper() respect = '!' in g x = y = z = 0.0 if respect and self.stat is not None: x = self.stat.g5x_offset[0] + self.stat.g92_offset[0] y = self.stat.g5x_offset[1] + self.stat.g92_offset[1] z = self.stat.g5x_offset[2] + self.stat.g92_offset[2] return glcanon_bake.RotationOffsets(respect_offsets=respect, coords=self.trajcoordinates.upper(), x=x, y=y, z=z) _rotation_offsets = rotation_offsets @property def _program_stale(self): """Whether the program's GPU buffers need re-uploading. The flag lives on the shared ProgramResource the draw and pick paths both use.""" return self.scene.program.resource.stale def _bake_program_geometry(self): self.scene.program.resource.upload(self.frame_context()) def _stack_mvp(self): """MVP folding the current explicit model-view stack into the frame projection - used by preview elements (axes, limits, cone, offsets, extents labels) positioned with the _mv_* stack helpers.""" return self.mv.mvp() # -- shared drawing primitives ----------------------------------------- # These live on glcanon_scene.Primitives (self.prim), which the parts reach # through the frame context; the shims keep the pre-scene names working. def _lines_to_array(self, points, color, alpha=1.0): return self.prim.lines_to_array(points, color, alpha) def _limit_color(self, cond): return self.prim.limit_color(self.colors, cond) def _draw_hershey(self, s, color, frac=0.0, bbox=False): self.prim.draw_hershey(self, s, color, frac, bbox) def _cone_mesh_verts(self): return self.prim.cone_mesh() def _draw_cone_core(self, color, mesh_verts=None): self.prim.draw_cone(self, color, mesh_verts) def select(self, x_view, y_view): """Select the program line under the cursor. Thin delegate to the Picker; the ID-buffer pick itself lives there.""" if self.canon is None: return self.set_highlight_line( self.picker.pick(self.frame_context(), x_view, y_view)) def stale_dlist(self, listname): # GPU-cache invalidation (was a GL display-list free). The baked program # VBOs replace the old program_*/select_* display lists, so invalidating # any of those names marks the bake stale and the next frame rebuilds the # buffers - preserving the legacy call surface (e.g. subclass calls to # stale_dlist('program_norapids')). Other names are now no-ops. if listname in ('program_rapids', 'program_norapids', 'select_rapids', 'select_norapids'): self.scene.program.invalidate() def update_highlight_variable(self,line): self.highlight_line = line def set_current_line(self, line): pass def set_highlight_line(self, line): if line == self.get_highlight_line(): return self.update_highlight_variable(line) # The highlight geometry is drawn by glcanon_scene.HighlightPart; here # we only recompute the centrepoint the selection recentres the view on. if line is not None and self.canon is not None: if self.is_foam(): x, y, z = self.canon.highlight(line, "XY") u, v, w = self.canon.highlight(line, "UV") x = (x+u)/2 y = (y+v)/2 z = (self.get_foam_z() + self.get_foam_w())/2 else: x, y, z = self.canon.highlight(line, self.get_geometry()) elif self.canon is not None: x = (self.canon.min_extents[X] + self.canon.max_extents[X])/2 y = (self.canon.min_extents[Y] + self.canon.max_extents[Y])/2 z = (self.canon.min_extents[Z] + self.canon.max_extents[Z])/2 else: x, y, z = 0.0, 0.0, 0.0 self.set_centerpoint(x, y, z) @with_context_swap def redraw_perspective(self): w = self.winfo_width() h = self.winfo_height() glViewport(0, 0, w, h) # Told, not queried: the quad-expanded line path works in pixels and # needs the drawable size, and this is where GL is given it. self.renderer.set_viewport(w, h) # Clear the background and depth buffer. glClearColor(*(self.colors['back'] + (0,))) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) # Explicit projection (glnav folds the eye translation in) and model-view # seed - no GL matrix stack. get_projection_matrix branches on # self.perspective, true here. self._projection = self.get_projection_matrix(w, h) self._mv_reset(self.get_modelview_matrix()) try: self.redraw() finally: glFlush() @with_context_swap def redraw_ortho(self): if not self.initialised: return w = self.winfo_width() h = self.winfo_height() glViewport(0, 0, w, h) # Told, not queried: the quad-expanded line path works in pixels and # needs the drawable size, and this is where GL is given it. self.renderer.set_viewport(w, h) # Clear the background and depth buffer. glClearColor(*(self.colors['back'] + (0,))) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) self._projection = self.get_projection_matrix(w, h) self._mv_reset(self.get_modelview_matrix()) try: self.redraw() finally: glFlush() def color_limit(self, cond): # The colour is now applied per label via _limit_color/_draw_hershey; # this keeps returning the predicate (used as the out-of-limit bbox flag # and by any external callers). return cond def show_extents(self): """Program dimension lines and labels. Kept as a method for the GUIs that call it directly; the drawing lives in the scene's extents part.""" if self.canon is not None: self.scene.extents.draw(self.frame_context()) def draw_cube(self, min_extents, max_extents, color=(1, 1, 1)): """ Draw a cube :param min_extents: Tuple of X,Y,Z Minimum Limits :param max_extents: Tuple of X,Y,Z Maximum Limits :param color: Tuple of RGB color values """ self.prim.draw_cube(self, min_extents, max_extents, color) def draw_bounding_box(self): """Draw a bounding box around the extents of the program if we skip loading the entire part.""" if self.canon is not None: self.scene.bounding_box.draw(self.frame_context()) def to_internal_linear_unit(self, v, unit=None): if unit is None: unit = self.stat.linear_units lu = (unit or 1) * 25.4 return v/lu def to_internal_units(self, pos, unit=None): if unit is None: unit = self.stat.linear_units lu = (unit or 1) * 25.4 lus = [lu, lu, lu, 1, 1, 1, lu, lu, lu] return [a/b for a, b in zip(pos, lus)] def soft_limits(self): def fudge(num): if abs(num) > 1e30: return 0 return num ax = self.stat.axis return ( self.to_internal_units([fudge(ax[i]['min_position_limit']) for i in range(3)]), self.to_internal_units([fudge(ax[i]['max_position_limit']) for i in range(3)])) def get_foam_z(self): if self.canon: return self.canon.foam_z return self.foam_z_height def get_foam_w(self): if self.canon: return self.canon.foam_w return self.foam_w_height def get_grid(self): if self.canon and self.canon.grid: return self.canon.grid return 5./25.4 def draw_grid(self): """Draw the ground grid. Override point: plasmac2 replaces this method on the instance and calls back into draw_grid_permuted.""" self.scene.grid.draw_default(self.frame_context()) def draw_grid_permuted(self, rotation, permutation, inverse_permutation): """Override point: plasmac2 replaces ``draw_grid`` and calls back in here directly, bypassing the scene - so this must enter the grid's scope itself, or the grid silently starts writing depth.""" ctx = self.frame_context() with self.scene.grid.scope(ctx): self.scene.grid.draw_permuted(ctx, rotation, permutation, inverse_permutation) def all_joints_homed(self): for i in range (self.stat.joints): if not self.stat.homed[i]: return False return True def one_or_more_on_limit(self): for i in range (self.stat.joints): if self.stat.limit[i]: return True return False def idx_for_home_or_limit_icon(self,string): # parse posstr and return encoded idx # Note: for non-identity kinematics after homing, # axis coordinate letters are displayed and home # or limit conditions are displayed using # allhomedicon and somelimiticon if self.hide_icons: return -1 # no icon display # special case for extra joints after homing: # allow display of individual joint limit icons if ( (not self.get_joints_mode()) and ("EJ" in string) ): # parse extra joint number: return int(string.replace(" ","").split(":")[0].split("EJ")[1]) if ( self.get_joints_mode() and (self.stat.kinematics_type != linuxcnc.KINEMATICS_IDENTITY) ): jnum = int(string.replace(" ","").split(":")[0]) return jnum if ( ("Vel" in string) or ("G5" in string) or ("TL" in string) or (len(string) == 0) ): return -1 # no icon display aletter = string.replace(" ","").split(":")[0] ans = 0 if ( aletter in ["X","Y","Z","A","B","C","U","V","W","Rad","Dia"] and self.stat.kinematics_type != linuxcnc.KINEMATICS_IDENTITY ): if self.all_joints_homed(): ans = ans -2 # allhomeicon on all letters if self.one_or_more_on_limit(): ans = ans -4 # limitedicon on all letters if (ans < 0): return ans # -2,-4,-6 if (aletter == "DTG"): return -1 if (aletter == "Rad"): return 0 if (aletter == "Dia"): return 0 if self.lathe_historical_config(self.trajcoordinates): if (aletter == "Z"): return 2 # Z for historical lathe return 0 # Rad or Dia if ( aletter in ["X","Y","Z","A","B","C","U","V","W"] and self.stat.kinematics_type == linuxcnc.KINEMATICS_IDENTITY ): return self.jnum_for_aletter(aletter, self.kinsmodule, self.trajcoordinates) else: return -1 # no icon display def show_icon_init(self): self.scene.overlay.reset_icons() def show_icon(self, idx, icon): """Draw a home/limit icon on the current DRO line. Kept as a method because hal_gremlin's format_dro() override interacts with it.""" self.scene.overlay.show_icon(idx, icon) def build_scene(self): """The scene this widget draws. Overridable by a hosting GUI that wants a different set or order of parts.""" return glcanon_scene.PreviewScene() def frame_context(self) -> glcanon_scene.FrameContext: """Build the narrow context the scene parts draw from. This is the whole coupling between the drawing code and this widget: the parts see these fields and nothing else. Flags every frame reads anyway are resolved here; hooks a hosting GUI may override, and values only some parts need, are passed as bound methods. """ return glcanon_scene.FrameContext( mv=self.mv, prim=self.prim, renderer=self.renderer, caps=self.renderer.caps, colors=self.colors, stat=self.stat, canon=self.canon, lp=self.lp, geometry=self.get_geometry(), is_lathe=self.is_lathe(), is_foam=self.is_foam(), foam_z=self.get_foam_z(), foam_w=self.get_foam_w(), limits=self.soft_limits(), joints_mode=self.get_joints_mode(), view=self.get_view(), width=self.winfo_width(), height=self.winfo_height(), show_program=self._resolve_show_program(), show_rapids=self.get_show_rapids(), show_extents=self.get_show_extents(), show_offsets=self.get_show_offsets(), show_limits=self.get_show_limits(), show_tool=self.get_show_tool(), show_live_plot=self.get_show_live_plot(), show_relative=self.get_show_relative(), show_metric=self.get_show_metric(), show_small_origin=self.show_small_origin, program_alpha=self.get_program_alpha(), grid_size=self.get_grid_size(), highlight_line=self.get_highlight_line(), enable_dro=self.enable_dro, cone_basesize=self.cone_basesize, disable_cone_scaling=self.disable_cone_scaling, view_tool_min_dia=self.view_tool_min_dia, preview_mvp=self._preview_mvp, to_internal_units=self.to_internal_units, to_internal_linear_unit=self.to_internal_linear_unit, color_limit=self.color_limit, draw_grid=self.draw_grid, posstrs=self.posstrs, font_info=self.get_font_info, current_tool=self.get_current_tool, icon_index=self.idx_for_home_or_limit_icon, show_icon=self.show_icon, user_plot=getattr(self, 'user_plot', None), ) def redraw(self): s = self.stat s.poll() linuxcnc.gui_rot_offsets(s.g5x_offset[0] + s.g92_offset[0], s.g5x_offset[1] + s.g92_offset[1], s.g5x_offset[2] + s.g92_offset[2]) self.scene.draw(self.frame_context()) def _resolve_show_program(self): """get_show_program(), refused for a file too large to preview. The size decision is made once per load in load_preview(); this only reads the flag, so no file is stat'ed per frame. """ if self.preview_too_large: return False return self.get_show_program() def lathe_historical_config(self,trajcoordinates): # detect historical lathe config with dummy joint 1 if (self.is_lathe() and (trajcoordinates == "XZ") and (self.get_num_joints() == 3)): return True return False def jnum_for_aletter(self,aletter,kinsmodule,trajcoordinates): aletter = aletter.upper() if "trivkins" in kinsmodule: return trajcoordinates.index(aletter) else: try: guess = trajcoordinates.index(aletter) return guess except: return "XYZABCUVW".index(aletter) def posstrs(self): s = self.stat limit = list(s.limit[:]) homed = list(s.homed[:]) spd = self.to_internal_linear_unit(s.current_vel) if not self.get_joints_mode() or self.no_joint_display: if self.get_show_commanded(): positions = s.position else: positions = s.actual_position if self.get_show_relative(): positions = [(i-j) for i, j in zip(positions, s.tool_offset)] positions = [(i-j) for i, j in zip(positions, s.g5x_offset)] t = -s.rotation_xy t = math.radians(t) _x = positions[X] _y = positions[Y] positions[X] = _x * math.cos(t) - _y * math.sin(t) positions[Y] = _x * math.sin(t) + _y * math.cos(t) positions = [(i-j) for i, j in zip(positions, s.g92_offset)] else: positions = list(positions) if self.get_a_axis_wrapped(): positions[3] = math.fmod(positions[3], 360.0) if positions[3] < 0: positions[3] += 360.0 if self.get_b_axis_wrapped(): positions[4] = math.fmod(positions[4], 360.0) if positions[4] < 0: positions[4] += 360.0 if self.get_c_axis_wrapped(): positions[5] = math.fmod(positions[5], 360.0) if positions[5] < 0: positions[5] += 360.0 positions = self.to_internal_units(positions) axisdtg = self.to_internal_units(s.dtg) g5x_offset = self.to_internal_units(s.g5x_offset) g92_offset = self.to_internal_units(s.g92_offset) tlo_offset = self.to_internal_units(s.tool_offset) dtg = self.to_internal_linear_unit(s.distance_to_go) if self.get_show_metric(): positions = self.from_internal_units(positions, 1) axisdtg = self.from_internal_units(axisdtg, 1) g5x_offset = self.from_internal_units(g5x_offset, 1) g92_offset = self.from_internal_units(g92_offset, 1) tlo_offset = self.from_internal_units(tlo_offset, 1) dtg *= 25.4 spd = spd * 25.4 spd = spd * 60 return self.dro_format(self.stat,spd,dtg,limit,homed,positions, axisdtg,g5x_offset,g92_offset,tlo_offset) else: return self.joint_dro_format(s,spd,self.get_num_joints(),limit, homed) # N.B. no conversion here because joint positions are unitless # joint_mode and display_joint # Note: this is overridden in other guis (then AXIS) for different dro behavior def joint_dro_format(self,s,spd,num_of_joints,limit, homed): posstrs = [" %s:% 9.4f" % i for i in zip(list(range(num_of_joints)), s.joint_actual_position)] droposstrs = posstrs return limit, homed, posstrs, droposstrs # Note: this is overridden in other guis (then AXIS) for different dro behavior def dro_format(self,s,spd,dtg,limit,homed,positions,axisdtg,g5x_offset,g92_offset,tlo_offset): if self.get_show_metric(): baseformat = "% 6s:" + self.dro_mm droformat = " " + baseformat + " DTG %1s:" + self.dro_mm offsetformat = "% 5s %1s:" + self.dro_mm + " G92 %1s:" + self.dro_mm rotformat = "% 5s %1s:" + self.dro_mm else: baseformat = "% 6s:" + self.dro_in droformat = " " + baseformat + " DTG %1s:" + self.dro_in offsetformat = "% 5s %1s:" + self.dro_in + " G92 %1s:" + self.dro_in rotformat = "% 5s %1s:" + self.dro_in diaformat = " " + baseformat posstrs = [] droposstrs = [] for i in range(linuxcnc.MAX_AXIS): a = "XYZABCUVW"[i] if s.axis_mask & (1<0 and (not self.get_show_offsets())): posstrs.append("Extra Joints:") for jno in range(self.get_num_joints() - self.stat.num_extrajoints, self.get_num_joints()): jval = self.stat.joint_actual_position[jno] jstr = " EJ%d:% 9.4f" % (jno,jval) if jno >= 10: jstr = " EJ%2d:% 9.4f" % (jno,jval) posstrs.append(jstr) return limit, homed, posstrs, droposstrs #: Kept for external callers; the tool part owns the table it draws from. lathe_shapes = glcanon_scene.ToolPart.LATHE_SHAPES def extents_info(self): if self.canon: mid = [(a+b)/2 for a, b in zip(self.canon.max_extents, self.canon.min_extents)] size = [(a-b) for a, b in zip(self.canon.max_extents, self.canon.min_extents)] else: mid = [0, 0, 0] size = [3, 3, 3] return mid, size def load_preview(self, f, canon, *args): self.set_canon(canon) self.preview_too_large = False canon.preview_incomplete = False try: result, seq = gcode.parse(f, canon, *args) except KeyboardInterrupt: # Aborted parse: extents cover only the parsed portion. Flag it so # callers do not treat the partial check as complete. canon.preview_incomplete = True canon.calc_extents() raise if result <= gcode.MIN_ERROR: self.canon.progress.nextphase(1) canon.calc_extents() self.stale_dlist('program_rapids') self.stale_dlist('program_norapids') # Parsed fully (extents and the run-time limit check stay valid); only # drawing is suppressed. if 0 < self.max_file_size and os.path.exists(f) \ and self.max_file_size < os.stat(f).st_size: self.preview_too_large = True log.warning("%s is larger than the %.0f MB preview limit; " "preview disabled for it. The program still runs, " "but without a graphical extents check.", f, self.max_file_size / (1024 * 1024)) return result, seq def from_internal_units(self, pos, unit=None): if unit is None: unit = self.stat.linear_units lu = (unit or 1) * 25.4 lus = [lu, lu, lu, 1, 1, 1, lu, lu, lu] return [a*b for a, b in zip(pos, lus)] # vim:ts=8:sts=4:sw=4:et: