Source code for kf.QtPanelScene

"""PyQt5 widgets to render a stage scene with multiple panels suspended on winch-driven lines.
"""

################################################################
# Written in 2019 by Garth Zeglin <garthz@cmu.edu>

# To the extent possible under law, the author has dedicated all copyright
# and related and neighboring rights to this software to the public domain
# worldwide. This software is distributed without any warranty.

# You should have received a copy of the CC0 Public Domain Dedication along with this software.
# If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.

################################################################
import math, logging

# for documentation on the PyQt5 API, see http://pyqt.sourceforge.net/Docs/PyQt5/index.html
from PyQt5 import QtCore, QtGui, QtWidgets

# set up logger for module
log = logging.getLogger('QtPanelScene')

# filter out most logging; the default is NOTSET which passes along everything
# log.setLevel(logging.WARNING)

################################################################
[docs]class QtPanelScene(QtWidgets.QWidget): """Custom widget representing a stage scene as a 2D cartoon view.""" def __init__(self): super().__init__() self.setMinimumSize(QtCore.QSize(100, 100)) self.setAutoFillBackground(True) # graphical state variables self.positions = [0.0]*8 # units are microsteps self.lights = [QtGui.QColor(255, 255, 255, 127) for i in range(4)] # Create a path representing the basic winch glyph. The default coordinates # have +X to the right, +Y down. The drawing units are millimeters. self.winch_symbol = QtGui.QPainterPath() self.winch_symbol.addEllipse(QtCore.QPointF(0.0, 0.0), 20.0, 20.0) # center, rx, ry self.winch_symbol.moveTo(-10.0, 0.0) self.winch_symbol.lineTo( 10.0, 0.0) self.winch_symbol.moveTo( 0.0, 10.0) self.winch_symbol.lineTo( 0.0,-10.0) # finish initialization self.show() return
[docs] def update_positions(self, positions): N = len(positions) self.positions[0:N] = positions self.repaint()
[docs] def set_intensity(self, axis, intensity): """Set the visual intensity of each stage lighting channel. This is actually implemented by changing the opacity of the rendered gradient ellipse representing the light. :param axis: either a integer axis number or list of axis numbers :param intensity: float or list of floats with values ranging from 0.0 (dark) to 1.0 (full bright) """ if isinstance(axis, int): self.lights[axis].setAlphaF(intensity) log.debug("Set intensity on %d to %f", axis, intensity) else: for i, alpha in zip(axis, intensity): self.lights[i].setAlphaF(alpha)
def _calculate_intersection(self, R1, R2, D, L): """Given line lengths R1 and R2 in millimeters, calculate the intersection of the circular loci with a suspended bar. Returns the (x, y) center location or None. The location is relative to the midpoint of the pulley points. This returns the positive solution since this cartoon uses native Qt coordinate orientation with +Y pointing down. See math/suspended_bar_kinematics.py for the derivation. :param R1: length of left line in millimeters :param R2: length of right line in millimeters :param D: distance between winches in millimeters :param L: length of the bar in millimeters """ x = (1/2)*R1**2/(D - L) - 1/2*R2**2/(D - L) # Prune the solutions where the endpoints of the bar would extend # outside the support region under the winches. if abs(x) > ((D/2)-(L/2)): return None else: yterm = -D**4 + 4*D**3*L - 6*D**2*L**2 + 2*D**2*R1**2 + 2*D**2*R2**2 + 4*D*L**3 - 4*D*L*R1**2 - \ 4*D*L*R2**2 - L**4 + 2*L**2*R1**2 + 2*L**2*R2**2 - R1**4 + 2*R1**2*R2**2 - R2**4 # Test whether a valid solution exists. E.g., if the lines retract # too much there is no feasible solution, and in practice the motors # would stall once the bar was under maximum tension. if yterm >= 0: y = 0.5 * math.sqrt(yterm) / (D - L) return x, y else: return None # === element drawing methods ============================================================ def _draw_lights(self, qp): """Draw a representation of the stage lighting as overlapping ellipses drawn with radial gradients.""" gradient = QtGui.QRadialGradient(QtCore.QPointF(0,0), 1000, QtCore.QPointF(0,-400)) # center, radius, focus gradient.setColorAt(0.0, QtCore.Qt.white) gradient.setColorAt(1.0, QtCore.Qt.transparent) brush = QtGui.QBrush(gradient) qp.setBrush(brush) qp.setPen(QtCore.Qt.NoPen) for i, light in enumerate(self.lights): brush.gradient().setColorAt(0.0, light) xl = -900 + i*600 yl = 0 qp.save() qp.translate(xl,yl) qp.drawEllipse(QtCore.QPointF(0,0), 1000, 1000) qp.restore() def _draw_winch(self, qp, x, y, position): """Draw a representation of a capstan winch as a circle with hub markings to make rotation visible. :param qp: Qt QPainter drawable :param x: horizontal location of center in millimeters :param y: vertical location of center in millimeters :param position: angular position of winch in microsteps """ qp.save() qp.translate(QtCore.QPointF(x, y)) # The default coordinate system rotation uses +Z pointing into the # screen; this changes sign so positive displacements are # counter-clockwise, i.e. right-hand-rule on the vector pointing *out* # of the screen. This rescales from microsteps to degrees. qp.rotate(-position*(360/800)) qp.drawPath(self.winch_symbol) qp.restore() return def _draw_panel(self, qp, color, positions, D=1600, L=600, diameter=40, R0=1000): """Draw a cartoon representation of a fabric panel supported on two lines driven by capstan winches. The drawing origin of the cartoon is the midpoint between the winches. Please note that the positive motor angles move the capstan CCW, so a positive left winch rotation retracts the line but positive right winch rotation extends the line. :param qp: Qt QPainter drawable :param color: QColor object with the translucent fabric color (alpha usually less than opaque) :param positions: two-element array [left, right] of angular winch positions in microsteps :param D: length of horizontal baseline between winches, in millimeters (800 steps/revolution) :param L: length of upper edge of fabric, in millimeters :param diameter: diameter of capstan, in millimeters :param R0: length of extended line at neutral position where motor angle is zero, in millimeters """ # calculate some useful coordinates HD = D/2 # distance from midpoint to each winch center radius = diameter/2 # capstan radius l_cw_x = -HD - radius # left counterweight horizontal position r_cw_x = HD + radius # right counterweight horizontal position cw_r = 2*diameter # counterweight radius H = 3*L/2 # height of fabric panel # Calculate the factor to scale microsteps to millimeters of line # travel. The winch is 800 units/rev and has a circumference of # (diameter * pi) millimeters linear_scaling = (diameter * math.pi) / 800 Y0 = 3/2*R0 # neutral length of counterweight lines in millimeters l_cw_y = linear_scaling*positions[0] # left counterweight vertical position relative to neutral r_cw_y = -linear_scaling*positions[1] # right counterweight vertical position relative to neutral R1 = R0 - l_cw_y # left extended line length in millimeters R2 = R0 - r_cw_y # right extended line length in millimeters # set up a the stroke and fill properties using the supplied color pen = QtGui.QPen(QtCore.Qt.black) pen.setWidthF(4) qp.setPen(pen) brush = QtGui.QBrush(color) qp.setBrush(brush) # draw the left and right winches self._draw_winch(qp, -HD, 0, positions[0]) self._draw_winch(qp, HD, 0, positions[1]) # Draw the left and right counterweights. qp.drawLine(QtCore.QPointF(l_cw_x, 0), QtCore.QPointF(l_cw_x, Y0+l_cw_y)) qp.drawLine(QtCore.QPointF(r_cw_x, 0), QtCore.QPointF(r_cw_x, Y0+r_cw_y)) qp.drawEllipse(QtCore.QPointF(l_cw_x, Y0+l_cw_y+cw_r), cw_r, cw_r) qp.drawEllipse(QtCore.QPointF(r_cw_x, Y0+r_cw_y+cw_r), cw_r, cw_r) # Calculate the possible intersection of loci, and if defined, draw lines representing the control lines. intersection = self._calculate_intersection(R1, R2, D, L) if intersection is not None: xc, yc = intersection qp.drawLine(QtCore.QPointF(-HD, 0), QtCore.QPointF(xc - L/2, yc)) qp.drawLine(QtCore.QPointF( HD, 0), QtCore.QPointF(xc + L/2, yc)) qp.drawRect(QtCore.QRectF(xc - L/2, yc, L, H)) else: # If intersection is not valid, draw each locus of possible line endpoints as a circle. # This will default to a filled circle, which will make the infeasible state very visible. qp.drawEllipse(QtCore.QPointF(-HD, 0), R1, R1) qp.drawEllipse(QtCore.QPointF( HD, 0), R2, R2) return # === Qt API methods ============================================================
[docs] def paintEvent(self, e): """Subclass implementation of parent QWidget class callback to repaint the graphics.""" geometry = self.geometry() width = geometry.width() height = geometry.height() # clear the background qp = QtGui.QPainter() qp.begin(self) qp.fillRect(QtCore.QRectF(0, 0, width, height), QtGui.QColor(20, 20, 20, 255)) qp.setRenderHint(QtGui.QPainter.Antialiasing) # Set up a coordinate system scaled to real-world millimeters, centered # in the visible area, keeping the minimum visible area in view. # define minimim visible area scene_width = 3500 scene_height = 2000 scene_aspect = scene_width / scene_height window_aspect = width / height if scene_aspect > window_aspect: scaling = width / scene_width else: scaling = height/scene_height qp.save() qp.translate(QtCore.QPointF(width/2, height/2)) qp.scale(scaling, scaling) # set some scene properties baseline = 3000 # winch baseline distance neutral_length = 1200 # neutral extended line length # draw the stage lighting as a background element self._draw_lights(qp) # locate the panel drawing origin in the center-top of the area qp.translate(QtCore.QPointF(-75, -800)) # draw the green panel, then slightly offset each subsequent panel system self._draw_panel(qp, QtGui.QColor(0, 255, 0, 31), self.positions[0:2], D=baseline, R0=neutral_length) qp.translate(QtCore.QPointF(100, 0)) self._draw_panel(qp, QtGui.QColor(0, 0, 255, 31), self.positions[2:4], D=baseline, R0=neutral_length) qp.translate(QtCore.QPointF(-50, -100)) self._draw_panel(qp, QtGui.QColor(255, 0, 0, 31), self.positions[4:6], D=baseline, R0=neutral_length) qp.translate(QtCore.QPointF(100, 0)) self._draw_panel(qp, QtGui.QColor(255, 255, 0, 31), self.positions[6:8], D=baseline, R0=neutral_length) # restore the initial unscaled coordinates qp.restore() qp.end()
################################################################