"""
These mathematics and geometry extensions subclass the :class:`~ggame.app.App`
and :class:`~ggame.sprite.Sprite` classes to create a framework for building
apps that mimic some of the functionality of online math tools like Geogebra.
This :mod:`~ggame.mathapp` module implements base classes for
:class:`~ggame.sprite.Sprite`-based classes defined in this module.
These extensions are very experimental and are not fully developed!
"""
from abc import ABCMeta, abstractmethod
from time import time
from math import sqrt
from collections import namedtuple
from ggame.sprite import Sprite
from ggame.asset import Color, LineStyle, ImageAsset
from ggame.app import App
[docs]class MathApp(App):
"""
MathApp is a subclass of the ggame :class:`~ggame.app.App` class. It
incorporates the following extensions:
* Support for zooming the display using the mouse wheel
* Support for click-dragging the display using the mouse button
* Automatic execution of step functions in all objects and sprites
sub-classed from :class:`_MathDynamic`.
:param float scale: Optional parameter sets the initial scale of the
display in units of pixels per logical unit. The default is 200.
:returns: MathApp instance
"""
_scale = 200 # pixels per unit
_xcenter = 0 # center of screen in units
_ycenter = 0
_mathVisualList = [] #
_mathDynamicList = []
_mathMovableList = []
_mathSelectableList = []
_mathStrokableList = []
_viewNotificationList = []
time = time()
def __init__(self, scale=_scale):
super().__init__()
MathApp.width = self.width
MathApp.height = self.height
MathApp._scale = scale # pixels per unit
# register event callbacks
self.listenMouseEvent("click", self._handleMouseClick)
self.listenMouseEvent("mousedown", self._handleMouseDown)
self.listenMouseEvent("mouseup", self._handleMouseUp)
self.listenMouseEvent("mousemove", self._handleMouseMove)
self.listenMouseEvent("wheel", self._handleMouseWheel)
self.mouseDown = False
self.mouseCapturedObject = None
self.mouseStrokedObject = None
self.mouseDownObject = None
self.mouseX = self.mouseY = None
self._touchAllVisuals()
self.selectedObj = None
MathApp.time = time()
def step(self):
"""
The step method overrides :func:`~ggame.app.App.step` in the
:class:`~ggame.app.App` class, executing step functions in all
objects subclassed from :class:`_MathDynamic`.
"""
MathApp.time = time()
for spr in self._mathDynamicList:
spr.step()
def _touchAllVisuals(self):
# touch all visual object assets to use scaling
for obj in self._mathVisualList:
obj._touchAsset(True)
[docs] @classmethod
def logicalToPhysical(cls, lp):
"""
Transform screen coordinates from logical to physical space. Output
depends on the current 'zoom' and 'pan' of the screen.
:param tuple(float,float) lp: Logical screen coordinates (x, y)
:rtype: tuple(float,float)
:returns: Physical screen coordinates (x, y)
"""
xxform = lambda xvalue, xscale, xcenter, physwidth: int((xvalue-xcenter)*xscale + physwidth/2)
yxform = lambda yvalue, yscale, ycenter, physheight: int(physheight/2 - (yvalue-ycenter)*yscale)
try:
return (xxform(lp[0], cls._scale, cls._xcenter, cls._win.width),
yxform(lp[1], cls._scale, cls._ycenter, cls._win.height))
except AttributeError:
return lp
[docs] @classmethod
def physicalToLogical(cls, pp):
"""
Transform screen coordinates from physical to logical space. Output
depends on the current 'zoom' and 'pan' of the screen.
:param tuple(float,float) lp: Physical screen coordinates (x, y)
:rtype: tuple(float,float)
:returns: Logical screen coordinates (x, y)
"""
xxform = lambda xvalue, xscale, xcenter, physwidth: (xvalue - physwidth/2)/xscale + xcenter
yxform = lambda yvalue, yscale, ycenter, physheight: (physheight/2 - yvalue)/yscale + ycenter
try:
return (xxform(pp[0], cls._scale, cls._xcenter, cls._win.width),
yxform(pp[1], cls._scale, cls._ycenter, cls._win.height))
except AttributeError:
return pp
[docs] @classmethod
def translateLogicalToPhysical(cls, pp):
"""
Transform screen translation from logical to physical space. Output
only depends on the current 'zoom' of the screen.
:param tuple(float,float) lp: Logical screen translation pair
(delta x, delta y)
:rtype: tuple(float,float)
:returns: Physical screen translation ordered pair (delta x, delta y)
"""
xxform = lambda xvalue, xscale: xvalue*xscale
yxform = lambda yvalue, yscale: -yvalue*yscale
try:
return (xxform(pp[0], cls._scale), yxform(pp[1], cls._scale))
except AttributeError:
return pp
[docs] @classmethod
def translatePhysicalToLogical(cls, pp):
"""
Transform screen translation from physical to logical space. Output
only depends on the current 'zoom' of the screen.
:param tuple(float,float) lp: Physical screen translation pair
(delta x, delta y)
:rtype: tuple(float,float)
:returns: Logical screen translation ordered pair (delta x, delta y)
"""
xxform = lambda xvalue, xscale: xvalue/xscale
yxform = lambda yvalue, yscale: -yvalue/yscale
try:
return (xxform(pp[0], cls._scale), yxform(pp[1], cls._scale))
except AttributeError:
return pp
def _handleMouseClick(self, event):
found = False
for obj in self._mathSelectableList:
if obj.physicalPointTouching((event.x, event.y)):
found = True
if not obj.selected:
obj.select()
self.selectedObj = obj
if not found and self.selectedObj:
self.selectedObj.unselect()
self.selectedObj = None
def _handleMouseDown(self, event):
self.mouseDown = True
self.mouseCapturedObject = None
self.mouseStrokedObject = None
for obj in self._mathSelectableList:
if obj.physicalPointTouching((event.x, event.y)):
obj.mousedown()
self.mouseDownObject = obj
break
for obj in self._mathMovableList:
if obj.physicalPointTouching((event.x, event.y)) and not (obj.strokable and obj.canstroke((event.x,event.y))):
self.mouseCapturedObject = obj
break
if not self.mouseCapturedObject:
for obj in self._mathStrokableList:
if obj.canstroke((event.x, event.y)):
self.mouseStrokedObject = obj
break
def _handleMouseUp(self, event):
if self.mouseDownObject:
self.mouseDownObject.mouseup()
self.mouseDownObject = None
self.mouseDown = False
self.mouseCapturedObject = None
self.mouseStrokedObject = None
def _handleMouseMove(self, event):
if not self.mouseX:
self.mouseX = event.x
self.mouseY = event.y
dx = event.x - self.mouseX
dy = event.y - self.mouseY
self.mouseX = event.x
self.mouseY = event.y
if self.mouseDown:
if self.mouseCapturedObject:
self.mouseCapturedObject.translate((dx, dy))
elif self.mouseStrokedObject:
self.mouseStrokedObject.stroke((self.mouseX,self.mouseY), (dx,dy))
else:
lmove = self.translatePhysicalToLogical((dx, dy))
MathApp._xcenter -= lmove[0]
MathApp._ycenter -= lmove[1]
self._touchAllVisuals()
self._viewNotify("translate")
def _handleMouseWheel(self, event):
zoomfactor = event.wheelDelta/100
zoomfactor = 1+zoomfactor if zoomfactor > 0 else 1+zoomfactor
if zoomfactor > 1.2:
zoomfactor = 1.2
elif zoomfactor < 0.8:
zoomfactor = 0.8
MathApp._scale *= zoomfactor
self._touchAllVisuals()
self._viewNotify("zoom")
@property
def viewPosition(self):
"""
Attribute is used to get or set the current logical coordinates
at the center of the screen as a tuple of floats (x,y).
"""
return (MathApp._xcenter, MathApp._ycenter)
@viewPosition.setter
def viewPosition(self, pos):
MathApp._xcenter, MathApp._ycenter = pos
self._touchAllVisuals()
self._viewNotify("translate")
[docs] @classmethod
def addViewNotification(cls, handler):
"""
Register a function or method to be called in the event the view
position or zoom changes.
:param function handler: The function or method to be called
:returns: Nothing
"""
cls._viewNotificationList.append(handler)
[docs] @classmethod
def removeViewNotification(cls, handler):
"""
Remove a function or method from the list of functions to be called
in the event of a view position or zoom change.
:param function handler: The function or method to be removed
:returns: Nothing
"""
cls._viewNotificationList.remove(handler)
def _viewNotify(self, viewchange):
for handler in self._viewNotificationList:
handler(viewchange = viewchange, scale = self._scale, center = (self._xcenter, self._ycenter))
[docs] @classmethod
def distance(cls, pos1, pos2):
"""
Utility for calculating the distance between any two points.
:param tuple(float,float) pos1: The first point
:param tuple(float,float) pos2: The second point
:rtype: float
:returns: The distance between the two points (using Pythagoras)
"""
return sqrt((pos2[0]-pos1[0])**2 + (pos2[1]-pos1[1])**2)
@property
def scale(self):
"""
Attribute reports the current view scale (pixels per logical unit).
"""
return self._scale
@property
def width(self):
"""
Attribute reports the physical screen width (pixels).
"""
return App._win.width
@width.setter
def width(self, value):
pass
@classmethod
def _addVisual(cls, obj):
""" FIX ME """
if isinstance(obj, _MathVisual):
cls._mathVisualList.append(obj)
@classmethod
def _removeVisual(cls, obj):
if isinstance(obj, _MathVisual) and obj in cls._mathVisualList:
cls._mathVisualList.remove(obj)
@classmethod
def _addDynamic(cls, obj):
if isinstance(obj, _MathDynamic) and not obj in cls._mathDynamicList:
cls._mathDynamicList.append(obj)
@classmethod
def _removeDynamic(cls, obj):
if isinstance(obj, _MathDynamic) and obj in cls._mathDynamicList:
cls._mathDynamicList.remove(obj)
@classmethod
def _addMovable(cls, obj):
if isinstance(obj, _MathVisual) and not obj in cls._mathMovableList:
cls._mathMovableList.append(obj)
@classmethod
def _removeMovable(cls, obj):
if isinstance(obj, _MathVisual) and obj in cls._mathMovableList:
cls._mathMovableList.remove(obj)
@classmethod
def _addSelectable(cls, obj):
if isinstance(obj, _MathVisual) and not obj in cls._mathSelectableList:
cls._mathSelectableList.append(obj)
@classmethod
def _removeSelectable(cls, obj):
if isinstance(obj, _MathVisual) and obj in cls._mathSelectableList:
cls._mathSelectableList.remove(obj)
@classmethod
def _addStrokable(cls, obj):
if isinstance(obj, _MathVisual) and not obj in cls._mathStrokableList:
cls._mathStrokableList.append(obj)
@classmethod
def _removeStrokable(cls, obj):
if isinstance(obj, _MathVisual) and obj in cls._mathStrokableList:
cls._mathStrokableList.remove(obj)
@classmethod
def _destroy(cls, *args):
"""
This will clean up any class level storage.
"""
App._destroy(*args) # hit the App class first
MathApp._mathVisualList = []
MathApp._mathDynamicList = []
MathApp._mathMovableList = []
MathApp._mathSelectableList = []
MathApp._mathStrokableList = []
MathApp._viewNotificationList = []
class _MathDynamic(metaclass=ABCMeta):
def __init__(self):
self._dynamic = False # not switched on, by default!
def destroy(self):
MathApp._removeDynamic(self)
def step(self):
pass
def Eval(self, val):
if callable(val):
self._setDynamic() # dynamically defined .. must step
return val
else:
return lambda : val
def _setDynamic(self):
MathApp._addDynamic(self)
self._dynamic = True
[docs]class _MathVisual(Sprite, _MathDynamic, metaclass=ABCMeta):
"""
Abstract Base Class for all visual, potentially dynamic objects.
:param Asset asset: A valid ggame asset object.
:param list args: A list of required positional or non-positional arguments
as named in the _posinputsdef and _nonposinputsdef lists overridden
by child classes.
:param \**kwargs:
See below
:Optional Keyword Arguments:
* **positioning** (*string*) One of 'logical' or 'physical'
* **size** (*int*) Size of the object (in pixels)
* **width** (*int*) Width of the object (in pixels)
* **color** (*Color*) Valid :class:`~ggame.asset.Color` object
* **style** (*LineStyle*) Valid :class:`~ggame.asset.LineStyle` object
"""
_posinputsdef = [] # a list of names (string) of required positional inputs
_nonposinputsdef = [] # a list of names (string) of required non positional inputs
_defaultsize = 15
_defaultwidth = 200
_defaultcolor = Color(0, 1)
_defaultstyle = LineStyle(1, Color(0, 1))
def __init__(self, asset, *args, **kwargs):
MathApp._addVisual(self)
#Sprite.__init__(self, asset, args[0])
_MathDynamic.__init__(self)
self._movable = False
self._selectable = False
self._strokable = False
self.selected = False
"""
True if object is currently selected by the UI.
"""
self.mouseisdown = False
"""
True if object is tracking UI mouse button as down.
"""
self._positioning = kwargs.get('positioning', 'logical')
# positional inputs
self._PI = namedtuple('PI', self._posinputsdef)
# nonpositional inputs
self._NPI = namedtuple('NPI', self._nonposinputsdef)
# standard inputs (not positional)
standardargs = ['size','width','color','style']
self._SI = namedtuple('SI', standardargs)
# correct number of args?
if len(args) != len(self._posinputsdef) + len(self._nonposinputsdef):
raise TypeError("Incorrect number of parameters provided")
self._args = args
# generated named tuple of functions from positional inputs
self._posinputs = self._PI(*[self.Eval(p) for p in args][:len(self._posinputsdef)])
self._getPhysicalInputs()
# first positional argument must be a sprite position!
Sprite.__init__(self, asset, self._pposinputs[0])
# generated named tuple of functions from nonpositional inputs
if len(self._nonposinputsdef) > 0:
self._nposinputs = self._NPI(*[self.Eval(p) for p in args][(-1*len(self._nonposinputsdef)):])
else:
self._nposinputs = []
self._stdinputs = self._SI(self.Eval(kwargs.get('size', self._defaultsize)),
self.Eval(kwargs.get('width', self._defaultwidth)),
self.Eval(kwargs.get('color', self._defaultcolor)),
self.Eval(kwargs.get('style', self._defaultstyle)))
self._sposinputs = self._PI(*[0]*len(self._posinputs))
self._spposinputs = self._PI(*self._pposinputs)
self._snposinputs = self._NPI(*[0]*len(self._nposinputs))
self._sstdinputs = self._SI(*[0]*len(self._stdinputs))
def step(self):
self._touchAsset()
def _saveInputs(self, inputs):
self._sposinputs, self._spposinputs, self._snposinputs, self._sstdinputs = inputs
def _getInputs(self):
self._getPhysicalInputs()
return (self._PI(*[p() for p in self._posinputs]),
self._PI(*self._pposinputs),
self._NPI(*[p() for p in self._nposinputs]),
self._SI(*[p() for p in self._stdinputs]))
def _getPhysicalInputs(self):
"""
Translate all positional inputs to physical
"""
pplist = []
if self._positioning == 'logical':
for p in self._posinputs:
pval = p()
try:
pp = MathApp.logicalToPhysical(pval)
except AttributeError:
pp = MathApp._scale * pval
pplist.append(pp)
else:
# already physical
pplist = [p() for p in self._posinputs]
self._pposinputs = self._PI(*pplist)
def _inputsChanged(self, saved):
return self._spposinputs != saved[1] or self._snposinputs != saved[2] or self._sstdinputs != saved[3]
[docs] def destroy(self):
MathApp._removeVisual(self)
MathApp._removeMovable(self)
MathApp._removeStrokable(self)
_MathDynamic.destroy(self)
Sprite.destroy(self)
def _updateAsset(self, asset):
if type(asset) != ImageAsset:
visible = self.GFX.visible
if App._win != None:
App._win.remove(self.GFX)
self.GFX.destroy()
self.asset = asset
self.GFX = self.asset.GFX
self.GFX.visible = visible
if App._win != None:
App._win.add(self.GFX)
self.position = self._pposinputs.pos
@property
def positioning(self):
"""
Whether object was created with 'logical' or 'physical' positioning.
"""
return self._positioning
@positioning.setter
def positioning(self, val):
pass
@property
def movable(self):
"""
Whether object can be moved. Set-able and get-able.
"""
return self._movable
@movable.setter
def movable(self, val):
if not self._dynamic:
self._movable = val
if val:
MathApp._addMovable(self)
else:
MathApp._removeMovable(self)
@property
def selectable(self):
"""
Whether object can be selected by the UI. Set-able and get-able.
"""
return self._selectable
@selectable.setter
def selectable(self, val):
self._selectable = val
if val:
MathApp._addSelectable(self)
else:
MathApp._removeSelectable(self)
@property
def strokable(self):
"""
Whether the object supports a click-drag input from the UI mouse.
Set-able and get-able.
"""
return self._strokable
@strokable.setter
def strokable(self, val):
self._strokable = val
if val:
MathApp._addStrokable(self)
else:
MathApp._removeStrokable(self)
[docs] def select(self):
"""
Place the object in a 'selected' state.
:param: None
:returns: None
"""
self.selected = True
[docs] def unselect(self):
"""
Place the object in an 'unselected' state.
:param: None
:returns: None
"""
self.selected = False
[docs] def mousedown(self):
"""
Inform the object of a 'mouse down' event.
:param: None
:returns: None
"""
self.mouseisdown = True
[docs] def mouseup(self):
"""
Inform the object of a 'mouse up' event.
:param: None
:returns: None
"""
self.mouseisdown = False
[docs] def processEvent(self, event):
"""
Inform the object of a generic ggame event.
:param event: The ggame event object to receive and process.
:returns: None
This method is intended to be overridden.
"""
pass
[docs] @abstractmethod
def physicalPointTouching(self, ppos):
"""
Determine if a physical point is considered to be touching this object.
:param tuple(int,int) ppos: Physical screen coordinates.
:rtype: boolean
:returns: True if touching, False otherwise.
This method **must** be overridden.
"""
pass
[docs] @abstractmethod
def translate(self, pdisp):
"""
Perform necessary processing in response to being moved by the mouse/UI.
:param tuple(int,int) pdisp: Translation vector (x,y) in physical screen
units.
:returns: None
This method **must** be overridden.
"""
pass
[docs] def stroke(self, ppos, pdisp):
"""
Perform necessary processing in response to click-drag action by the
mouse/UI.
:param tuple(int,int) ppos: Physical coordinates of stroke start.
:param tuple(int,int) pdisp: Translation vector of stroke action in
physical screen units.
:returns: None
This method is intended to be overridden.
"""
pass
[docs] def canStroke(self, ppos):
"""
Can the object respond to beginning a stroke action at the given
position.
:param tuple(int,int) ppos: Physical coordinates of stroke start.
:rtype: Boolean
:returns: True if the object can respond, False otherwise.
This method is intended to be overridden.
"""
return False
def _touchAsset(self, force = False):
inputs = self._getInputs()
changed = self._inputsChanged(inputs)
if changed:
self._saveInputs(inputs)
if changed or force:
self._updateAsset(self._buildAsset())
@abstractmethod
def _buildAsset(self):
pass