"""
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
"""
_DEFAULTSCALE = 200
scale = _DEFAULTSCALE # pixels per unit
_xcenter = 0 # center of screen in units
_ycenter = 0
_mathVisualList = []
_mathDynamicList = []
_mathMovableList = []
_mathSelectableList = []
_mathStrokableList = []
_viewNotificationList = []
time = 0
def __init__(self, scale=_DEFAULTSCALE):
MathApp.time = 0
self._starttime = time()
super().__init__()
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._mouse_captured_object = None
self._mouse_stroked_object = None
self._mouse_down_object = None
self._mouse_x = self._mouse_y = None
self._touchAllVisuals()
self._selectedobj = None
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() - self._starttime
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)
"""
def xxform(xvalue, xscale, xcenter, physwidth):
"""X transform"""
return int((xvalue - xcenter) * xscale + physwidth / 2)
def yxform(yvalue, yscale, ycenter, physheight):
"""Y transform"""
return 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)
"""
def xxform(xvalue, xscale, xcenter, physwidth):
"""X transform"""
return (xvalue - physwidth / 2) / xscale + xcenter
def yxform(yvalue, yscale, ycenter, physheight):
"""Y transform"""
return (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)
"""
def xxform(xvalue, xscale):
"""X transform"""
return xvalue * xscale
def yxform(yvalue, yscale):
"""Y transform"""
return -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)
"""
def xxform(xvalue, xscale):
"""X transform"""
return xvalue / xscale
def yxform(yvalue, yscale):
"""Y transform"""
return -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._mouse_captured_object = None
self._mouse_stroked_object = None
for obj in self._mathSelectableList:
if obj.physicalPointTouching((event.x, event.y)):
obj.mousedown()
self._mouse_down_object = 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._mouse_captured_object = obj
break
if not self._mouse_captured_object:
for obj in self._mathStrokableList:
if obj.canstroke((event.x, event.y)):
self._mouse_stroked_object = obj
break
def _handleMouseUp(self, _event):
if self._mouse_down_object:
self._mouse_down_object.mouseup()
self._mouse_down_object = None
self._mousedown = False
self._mouse_captured_object = None
self._mouse_stroked_object = None
def _handleMouseMove(self, event):
if not self._mouse_x:
self._mouse_x = event.x
self._mouse_y = event.y
dx = event.x - self._mouse_x
dy = event.y - self._mouse_y
self._mouse_x = event.x
self._mouse_y = event.y
if self._mousedown:
if self._mouse_captured_object:
self._mouse_captured_object.translate((dx, dy))
elif self._mouse_stroked_object:
self._mouse_stroked_object.stroke(
(self._mouse_x, self._mouse_y), (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 view_position(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)
@view_position.setter
def view_position(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)
@classmethod
def addVisual(cls, obj):
"""
Add a visual object to the visual object list.
:param object obj: The object to add
:returns: None
"""
if isinstance(obj, _MathVisual):
cls._mathVisualList.append(obj)
@classmethod
def removeVisual(cls, obj):
"""
Remove a visual object from the visual object list.
:param object obj: The object to remove
:returns: None
"""
if isinstance(obj, _MathVisual) and obj in cls._mathVisualList:
cls._mathVisualList.remove(obj)
@classmethod
def addDynamic(cls, obj):
"""
Add a dynamic object to the dynamic object list.
:param object obj: The object to add
:returns: None
"""
if isinstance(obj, _MathDynamic) and obj not in cls._mathDynamicList:
cls._mathDynamicList.append(obj)
@classmethod
def removeDynamic(cls, obj):
"""
Remove a dynamic object from the dynamic object list.
:param object obj: The object to remove
:returns: None
"""
if isinstance(obj, _MathDynamic) and obj in cls._mathDynamicList:
cls._mathDynamicList.remove(obj)
@classmethod
def addMovable(cls, obj):
"""
Add a movable object to the movable object list.
:param object obj: The object to add
:returns: None
"""
if isinstance(obj, _MathVisual) and obj not in cls._mathMovableList:
cls._mathMovableList.append(obj)
@classmethod
def removeMovable(cls, obj):
"""
Remove a movable object from the movable object list.
:param object obj: The object to remove
:returns: None
"""
if isinstance(obj, _MathVisual) and obj in cls._mathMovableList:
cls._mathMovableList.remove(obj)
@classmethod
def addSelectable(cls, obj):
"""
Add a selectable object to the selectable object list.
:param object obj: The object to add
:returns: None
"""
if isinstance(obj, _MathVisual) and obj not in cls._mathSelectableList:
cls._mathSelectableList.append(obj)
@classmethod
def removeSelectable(cls, obj):
"""
Remove a selectable object from the selectable object list.
:param object obj: The object to remove
:returns: None
"""
if isinstance(obj, _MathVisual) and obj in cls._mathSelectableList:
cls._mathSelectableList.remove(obj)
@classmethod
def addStrokable(cls, obj):
"""
Add a strokable object to the strokable object list.
:param object obj: The object to add
:returns: None
"""
if isinstance(obj, _MathVisual) and obj not in cls._mathStrokableList:
cls._mathStrokableList.append(obj)
@classmethod
def removeStrokable(cls, obj):
"""
Remove a strokable object from the strokable object list.
:param object obj: The object to remove
:returns: None
"""
if isinstance(obj, _MathVisual) and obj in cls._mathStrokableList:
cls._mathStrokableList.remove(obj)
@classmethod
def destroy(cls):
"""
This will clean up any class level storage.
"""
App.destroy() # hit the App class first
MathApp.time = 0
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):
"""
Destroy resources, if any and remove from global lists.
"""
MathApp.removeDynamic(self)
def step(self):
"""
Override in your child class to perform periodic processing.
"""
def eval(self, val):
"""
Evaluate a potentially callable argument, returning a callable
object.
:param val: A simple variable or function reference that can be
called without any argument, returning a value.
:rtype: function
:returns: A function that can be called to retrieve the value passed
in.
"""
if callable(val):
self._setDynamic() # dynamically defined .. must step
return val
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
"""
# a list of names (string) of required positional inputs
_posinputsdef = []
# a list of names (string) of required non positional inputs
_nonposinputsdef = []
_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 self._nonposinputsdef:
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))
[docs] 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 not isinstance(asset, ImageAsset):
visible = self.gfx.visible
if MathApp.win is not None:
MathApp.win.remove(self.gfx)
self.gfx.destroy()
self.asset = asset
self.gfx = self.asset.gfx
self.gfx.visible = visible
if MathApp.win is not None:
MathApp.win.add(self.gfx)
if hasattr(self._pposinputs, "pos"):
self.position = getattr(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.
"""
[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.
"""
[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.
"""
[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.
"""
[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.
"""
[docs] def touchAsset(self, force=False):
"""
Check to see if an asset needs to be updated it and if so (or forced)
call the :func:`_updateAsset` method.
"""
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