# fplot.py Graph plotting extension for nanogui # Now clips out of range lines # The MIT License (MIT) # # Copyright (c) 2018 Peter Hinch # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. from gui.core.nanogui import DObject, circle from cmath import rect, pi from micropython import const from array import array type_gen = type((lambda: (yield))()) # Line clipping outcode bits _TOP = const(1) _BOTTOM = const(2) _LEFT = const(4) _RIGHT = const(8) # Bounding box for line clipping _XMAX = const(1) _XMIN = const(-1) _YMAX = const(1) _YMIN = const(-1) class Curve(): @staticmethod def _outcode(x, y): oc = _TOP if y > 1 else 0 oc |= _BOTTOM if y < -1 else 0 oc |= _RIGHT if x > 1 else 0 oc |= _LEFT if x < -1 else 0 return oc def __init__(self, graph, color, populate=None, origin=(0, 0), excursion=(1, 1)): if not isinstance(self, PolarCurve): # Check not done in subclass if isinstance(graph, PolarGraph) or not isinstance(graph, CartesianGraph): raise ValueError('Curve must use a CartesianGraph instance.') self.graph = graph self.origin = origin self.excursion = excursion self.color = color if color is not None else graph.fgcolor self.lastpoint = None self.newpoint = None if populate is not None and self._valid(populate): for x, y in populate: self.point(x, y) def _valid(self, populate): if not isinstance(populate, type_gen): raise ValueError('populate must be a generator.') return True def point(self, x=None, y=None): if x is None or y is None: self.newpoint = None self.lastpoint = None return self.newpoint = self._scale(x, y) # In-range points scaled to +-1 bounding box if self.lastpoint is None: # Nothing to plot. Save for next line. self.lastpoint = self.newpoint return res = self._clip(*(self.lastpoint + self.newpoint)) # Clip to +-1 box if res is not None: # Ignore lines which don't intersect self.graph.line(res[0:2], res[2:], self.color) self.lastpoint = self.newpoint # Scaled but not clipped # Cohen–Sutherland line clipping algorithm # If self.newpoint and self.lastpoint are valid clip them so that both lie # in +-1 range. If both are outside the box return None. def _clip(self, x0, y0, x1, y1): oc1 = self._outcode(x0, y0) oc2 = self._outcode(x1, y1) while True: if not oc1 | oc2: # OK to plot return x0, y0, x1, y1 if oc1 & oc2: # Nothing to do return oc = oc1 if oc1 else oc2 if oc & _TOP: x = x0 + (_YMAX - y0)*(x1 - x0)/(y1 - y0) y = _YMAX elif oc & _BOTTOM: x = x0 + (_YMIN - y0)*(x1 - x0)/(y1 - y0) y = _YMIN elif oc & _RIGHT: y = y0 + (_XMAX - x0)*(y1 - y0)/(x1 - x0) x = _XMAX elif oc & _LEFT: y = y0 + (_XMIN - x0)*(y1 - y0)/(x1 - x0) x = _XMIN if oc is oc1: x0, y0 = x, y oc1 = self._outcode(x0, y0) else: x1, y1 = x, y oc2 = self._outcode(x1, y1) def _scale(self, x, y): # Scale to +-1.0 x0, y0 = self.origin xr, yr = self.excursion xs = (x - x0) / xr ys = (y - y0) / yr return xs, ys class PolarCurve(Curve): # Points are complex def __init__(self, graph, color, populate=None): if not isinstance(graph, PolarGraph): raise ValueError('PolarCurve must use a PolarGraph instance.') super().__init__(graph, color) if populate is not None and self._valid(populate): for z in populate: self.point(z) def point(self, z=None): if z is None: self.newpoint = None self.lastpoint = None return self.newpoint = self._scale(z.real, z.imag) # In-range points scaled to +-1 bounding box if self.lastpoint is None: # Nothing to plot. Save for next line. self.lastpoint = self.newpoint return res = self._clip(*(self.lastpoint + self.newpoint)) # Clip to +-1 box if res is not None: # At least part of line was in box start = res[0] + 1j*res[1] end = res[2] + 1j*res[3] self.graph.cline(start, end, self.color) self.lastpoint = self.newpoint # Scaled but not clipped class TSequence(Curve): def __init__(self, graph, color, size, yorigin=0, yexc=1): super().__init__(graph, color, origin=(0, yorigin), excursion=(1, yexc)) self.data = array('f', (0 for _ in range(size))) self.cur = 0 self.size = size self.count = 0 def add(self, v): p = self.cur size = self.size self.data[self.cur] = v self.cur += 1 self.cur %= size if self.count < size: self.count += 1 x = 0 dx = 1/size for _ in range(self.count): self.point(x, self.data[p]) x -= dx p -= 1 p %= size self.point() class Graph(DObject): def __init__(self, writer, row, col, height, width, fgcolor, bgcolor, bdcolor, gridcolor): super().__init__(writer, row, col, height, width, fgcolor, bgcolor, bdcolor) super().show() # Draw border self.x0 = col self.x1 = col + width self.y0 = row self.y1 = row + height if gridcolor is None: gridcolor = self.fgcolor self.gridcolor = gridcolor def clear(self): self.show() # Clear working area class CartesianGraph(Graph): def __init__(self, writer, row, col, *, height=90, width = 120, fgcolor=None, bgcolor=None, bdcolor=None, gridcolor=None, xdivs=10, ydivs=10, xorigin=5, yorigin=5): super().__init__(writer, row, col, height, width, fgcolor, bgcolor, bdcolor, gridcolor) self.xdivs = xdivs self.ydivs = ydivs self.x_axis_len = max(xorigin, xdivs - xorigin) * width / xdivs # Max distance from origin in pixels self.y_axis_len = max(yorigin, ydivs - yorigin) * height / ydivs self.xp_origin = self.x0 + xorigin * width / xdivs # Origin in pixels self.yp_origin = self.y0 + (ydivs - yorigin) * height / ydivs self.xorigin = xorigin self.yorigin = yorigin self.show() def show(self): super().show() # Clear working area ssd = self.device x0 = self.x0 x1 = self.x1 y0 = self.y0 y1 = self.y1 if self.ydivs > 0: dy = self.height / (self.ydivs) # Y grid line for line in range(self.ydivs + 1): color = self.fgcolor if line == self.yorigin else self.gridcolor ypos = round(self.y1 - dy * line) ssd.hline(x0, ypos, x1 - x0, color) if self.xdivs > 0: width = x1 - x0 dx = width / (self.xdivs) # X grid line for line in range(self.xdivs + 1): color = self.fgcolor if line == self.xorigin else self.gridcolor xpos = round(x0 + dx * line) ssd.vline(xpos, y0, y1 - y0, color) # Called by Curve def line(self, start, end, color): # start and end relative to origin and scaled -1 .. 0 .. +1 xs = round(self.xp_origin + start[0] * self.x_axis_len) ys = round(self.yp_origin - start[1] * self.y_axis_len) xe = round(self.xp_origin + end[0] * self.x_axis_len) ye = round(self.yp_origin - end[1] * self.y_axis_len) self.device.line(xs, ys, xe, ye, color) class PolarGraph(Graph): def __init__(self, writer, row, col, *, height=90, fgcolor=None, bgcolor=None, bdcolor=None, gridcolor=None, adivs=3, rdivs=4): super().__init__(writer, row, col, height, height, fgcolor, bgcolor, bdcolor, gridcolor) self.adivs = adivs * 2 # No. of divisions of Pi radians self.rdivs = rdivs self.radius = round(height / 2) # Unit: pixels self.xp_origin = self.x0 + self.radius # Origin in pixels self.yp_origin = self.y0 + self.radius self.show() def show(self): super().show() # Clear working area ssd = self.device x0 = self.x0 y0 = self.y0 radius = self.radius adivs = self.adivs rdivs = self.rdivs diam = 2 * radius if rdivs > 0: for r in range(1, rdivs + 1): circle(ssd, self.xp_origin, self.yp_origin, round(radius * r / rdivs), self.gridcolor) if adivs > 0: v = complex(1) m = rect(1, pi / adivs) for _ in range(adivs): self.cline(-v, v, self.gridcolor) v *= m ssd.vline(x0 + radius, y0, diam, self.fgcolor) ssd.hline(x0, y0 + radius, diam, self.fgcolor) def cline(self, start, end, color): # start and end are complex, 0 <= magnitude <= 1 height = self.radius # Unit: pixels xs = round(self.xp_origin + start.real * height) ys = round(self.yp_origin - start.imag * height) xe = round(self.xp_origin + end.real * height) ye = round(self.yp_origin - end.imag * height) self.device.line(xs, ys, xe, ye, color)