1495 lines
62 KiB
Python
1495 lines
62 KiB
Python
# This is a component of AXIS, a front-end for emc
|
|
# Copyright 2004, 2005, 2006 Jeff Epler <jepler@unpythonic.net>
|
|
#
|
|
# 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<<i):
|
|
posstrs.append(baseformat % (a, positions[i]))
|
|
droposstrs.append(droformat % (a, positions[i], a, axisdtg[i]))
|
|
|
|
droposstrs.append("")
|
|
|
|
for i in range(linuxcnc.MAX_AXIS):
|
|
index = s.g5x_index
|
|
if index < 7:
|
|
label = "G5%d" % (index+3)
|
|
else:
|
|
label = "G59.%d" % (index-6)
|
|
|
|
a = "XYZABCUVW"[i]
|
|
if s.axis_mask & (1<<i):
|
|
droposstrs.append(offsetformat % (label, a, g5x_offset[i], a, g92_offset[i]))
|
|
droposstrs.append(rotformat % (label, 'R', s.rotation_xy))
|
|
|
|
droposstrs.append("")
|
|
for i in range(linuxcnc.MAX_AXIS):
|
|
a = "XYZABCUVW"[i]
|
|
if s.axis_mask & (1<<i):
|
|
droposstrs.append(rotformat % ("TLO", a, tlo_offset[i]))
|
|
|
|
if self.is_lathe():
|
|
posstrs[0] = baseformat % ("Rad", positions[0])
|
|
posstrs.insert(1, baseformat % ("Dia", positions[0]*2.0))
|
|
droposstrs[0] = droformat % ("Rad", positions[0], "R", axisdtg[0])
|
|
droposstrs.insert(1, diaformat % ("Dia", positions[0]*2.0))
|
|
|
|
if self.get_show_machine_speed():
|
|
posstrs.append(baseformat % ("Vel", spd))
|
|
droposstrs.append(diaformat % ("Vel", spd))
|
|
|
|
if self.get_show_distance_to_go():
|
|
posstrs.append(baseformat % ("DTG", dtg))
|
|
|
|
# show extrajoints (if not showing offsets)
|
|
if (self.stat.num_extrajoints >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:
|