Skip to content
Snippets Groups Projects
Commit 35b69d82 authored by moon2's avatar moon2 :speech_balloon:
Browse files

widgets

parent 0cba868d
No related branches found
No related tags found
1 merge request!713Widgets
``st3m.ui.widgets`` module
==========================
Basic Usage
-----------
flow3r widgets are objects that take raw user input and transform it in some or the other useful data. While this term
is often strongly associated with a visual output, we use it in a more generic sense: The output of a widget doesn't
necessarily go to the screen, but may be routed wherever.
.. py:class:: Widget
.. py:method:: think(ins: InputState, delta_ms: int) -> None
Takes input from the hardware drivers and updates the state of the widget accordingly.
.. py:method:: on_exit() -> None
Suspends the widget. Should be called when ``.think()`` will not be executed anymore for a while
to make sure no stale data is used when the widget is reactivated later. This also allows the widget to
deallocate resources it might be using. Always call when application is exited.
.. py:method:: on_enter() -> None
Prepares/unsuspends the widget for immediate impending use. This may allocate resources needed for data processing
etc.
The typical pattern for using widgets can be very simple. Since in many cases all the methods above are applied to all widgets at
the same time, it is convenient to keep a list of all widgets, but for easily readable attribute access it makes also sense to keep
a seperate named reference for each widget like so:
.. code-block:: python
from st3m.ui import widgets
class App(Application):
def __init__(self, app_ctx):
super().__init__(app_ctx)
# keep direct references around for easy access...
self.some_widget = widgets.SomeWidget()
self.other_widget = widgets.OtherWidget()
# ...but also put them in a list for batch processing
self.widgets = [self.some_widget, self.other_widget]
def on_enter(self, vm):
super().on_enter(vm)
for widget in self.widgets:
widget.on_enter()
def on_exit(self):
super().on_exit()
for widget in self.widgets:
widget.on_exit()
def think(self, ins, delta_ms):
super().think(ins, delta_ms)
for widget in self.widgets:
widget.think(ins, delta_ms)
some_value = self.some_widget.attribute
If you forget to call ``on_exit()``/``on_enter()`` you might not notice during testing but get strange bugs when people exit and
reenter your application - it's best to always keep a list of all your widgets around so that you don't forget these method calls,
If you don't need all your widgets all the time and want to save CPU by not calling ``.think()`` on all of them, it is also advised
to use ``on_exit()``/``on_enter()`` as appropriate to avoid unwanted side effects.
The primary motivation of widgets is to provide **captouch interaction processing**, but these APIs are a bit more complicated,
so we start with the simpler group of sensor widgets:
Sensor Widgets
--------------
These widgets provide common mathematical transformations and filtering of sensor data. They are designed to easily enhance
user interfaces with motion control and similar interactions.
.. py:class:: Altimeter(Widget)
.. py:method:: __init__(ref_temperature: Optional(float) = 15, filter_stages: int = 4, filter_coeff: float = 0.7) -> None
Initializes a pressure-based altimeter. ``ref_temperature`` initializes the attribute of the same name. Since the
``meters_above_sea`` output is very noisy a multistage pole filter is provided. The number of stages is set by
``filter_stages`` and must be 0 or greater. ``filter_coeff`` initializes the attribute of the same name.
.. py:attribute:: meters_above_sea
:type: float
Rough estimate of altitude in meters. The "above sea" bit is mostly theoretical, see ``.temperature``, it is best used
as a relative measurement. Very sensitive to pretty much everything, like moving your arms, closing windows and doors,
wind, you name it. Heavy filtering is recommended, meaning for practical purposes you may expect a very slow response
time in the magnitude of >10s.
.. py:attribute:: ref_temperature
:type: Optional(float)
Set to None to use the built-in temperature sensor for calculations, else use the value supplied here - wait, why
wouldn't you use the built-in sensor? The reason for this that temperature is just a proxy of the density curve of the
air column, from your position all the way up into space! This total weight is the main source of local pressure, so
we can use it to estimate how much air column there is on top of you, i.e. how high up in the athmosphere you are. So
if you're in a heated room, your local temperature is misrepresentative of most of that column, and as soon as you step
outside (or in a differently heated room) your reading will jump all over the place. Using the integrated sensor therefore
doesn't really make the data better unless you are outside - it just makes it more jumpy.
Pressure between rooms is fairly consistent, albeit noisy. Say we don't care for the absolute height, but how height
changes over time as we move flow3r up and down: Using a constant temperature guesstimate at worst introduces a minor
scaling error but removes all jumps from local temperature changes. It is almost always the better option to ignore it.
If you do want to perform actual absolute altimetry we advise polling weather services for local temperature data. Also
barometric pressure has other sources of error that you might take into account. It's probably best if widgets don't
silently connect to the web to poll data, so this will never be a fully automatic feature.
.. py:attribute:: filter_coeff
:type: float
Cutoff coefficient for all filter stages. Should typically be between 0 and 1. Each stages is updated once per ``.think()``
by the rule ``output = input * (1-filter_coeff) + output * filter_coeff``, so that higher ``filter_coeff`` values result in
slower response. Changing this parameter while the widget is running will not result in instant response as the change
needs to propagate through the filter first, if you need two different response behaviors at the same time it is best to
create two seperate instances or filter externally.
*Note: This filter is not synchronized to actual data refresh rate but rather to the think rate, meaning that the same
sample may occur in the buffer multiple times in succession or that data points might be skipped, and that response time
is a function of think rate. The actual data refresh rate is >20ms.*
.. py:class:: Inclinometer(Widget)
.. py:method:: __init__(buffer_len: int = 2) -> None:
Initializes a inclinometer. Noise can be removed with a windowed averaging filter, ``buffer_len`` sets the maximum window
length of that filter. At runtime you may switch between filter lengths with the ``filter_len`` attribute as you wish, but
its maximum must be set at initialization. Must be greater than 0.
.. py:attribute:: inclination
:type: float
Polar coordinates: How much flow3r is tilted compared to lying face-up on a table, in radians. Facing up is represented
by a value of 0, facing down by π, sideways is π/2 et cetera.
Range: [0..π]
.. py:attribute:: azimuth
:type: float
Polar coordinates: Informally this parameter represents which petal is pointing up most: Say flow3r is upright
(``.inclination`` = π/2), a value of 0 means the USB-C port is pointing up. A positive value indicates that it is rotated
clockwise from there, as a steering wheel analogy you're "turning right". Of course this value works just the same for all
values of ``inclination``, but there is a caveat: If fully facing up or facing down, so that no petal is pointing up more than the
others, this parameter will jump around with the random noise it picks up. If this causes you issues it's best to check
``inclination`` whether it is tilted enough for your purposes.
Range: [-π..π]
.. py:attribute:: roll
:type: float
Aviation coordinates: How far flow3r is tilted sideways to the right, in radians.
Range: [-π..π]
See coordinate system note at the ``pitch`` attribute description.
.. py:attribute:: pitch
:type: float
Aviation coordinates: How far flow3r is tilted backwards, in radians.
Range: [-π..π]
*Coordinate system note:* Isn't yaw missing here? Yes, but we can't obtain it from the gravity vector, so we're skipping it.
Also you might notice that normally the pitch angle only covers a range of π, this implementation however spans the full circle.
This has a side effect: Normally, the pitch angle range is limited to π, but here we go full circle. We do this to avoid
a discontinuity that would normally jump between interpreting, say, a unrolled pitch-down to a half-rolled pitch-up. The price
we pay for this is that our modified coordinate system fails when facing down: With full range on both angles it is undecidable
whether these positions come from rolling or pitching by an angle of π. We distribute it based on ``azimuth`` instead, which
tends to be jumpy in that area, so both ``pitch`` and ``roll`` experience full-range random jumps in that zone.
.. py:attribute:: filter_len
:type: int
Sets the length of the windowed averaging filter. Copies the value ``buffer_len`` at initialization. Must be greater than
zero and smaller or equal to ``buffer_len``. This parameter only affects the values of the output attributes and doesn't
change any of the available data, so you may even change it several times within the same ``think`` cycle and retrieve the
same output attributes with different response times if needed.
*Note: This filter is not synchronized to actual data refresh rate but rather to the think rate, meaning that the same
sample may occur in the buffer multiple times in succession or that data points might be skipped, and that response time
is a function of think rate. The actual data refresh rate is >10ms.*
Captouch Widgets
----------------
The raw output data of the captouch driver is quite rough around the edges as described in the ``captouch`` module documentation.
The widgets provided here aim to provide an intermediate layer to remove all the pitfalls that this poses; you might think that
many of these are trivial and barely deserving of their own classes, but the devil is in the details. They are not without quirks,
but if we ever figure out how to fix them these updates will directly benefit your application, and there is value in having
consistent quirks across the entire userland if possible.
These widgets often don't work with the default captouch driver configuration. To make it easy to generate a configuration that
works for all widgets used at a given time, each widget can add its requirements to a ``captouch.Config`` object. Typically this
is done at initialization:
.. code-block:: python
from st3m.ui import widgets
import captouch
class App(Application):
def __init__(self, app_ctx):
super().__init__(app_ctx)
# create a default configuration
self.captouch_config = captouch.Config.default()
# create some widgets and add requirements to the config above
self.slider = widgets.Slider(self.captouch_config, 4)
self.scroller = widgets.Scroller(self.captouch_config, 2)
self.widgets = [self.slider, self.scroller]
def on_enter(self, vm):
super().on_enter(vm)
# apply the configuration when entering
self.captouch_config.apply()
for widget in self.widgets:
widget.on_enter()
# (other methods same as in the general Widget example above)
The example above is a bit wasteful: The ``.default()`` configuration activates a lot of data channels that we might not be using
and which slow down the data rates of the channels that we actually care about. In the example above, the ``Scroller`` widget
would benefit a lot from the 3x-ish increase in data rate that starting with an ``.empty()`` configuration would yield (see
the ``captouch`` module documentation for details).
However, not all captouch data access is happening via widgets; an application might use edges from ``st3m.input.InputController``
or primitive position output from ``st3m.input.InputState``. When starting with a ``.default()`` configuration and only adding
widget requirements these are guaranteed to work; however, for best performance it is ideal to start with an ``.empty()`` one
and add them manually as we will demonstrate below.
One more case would be that not all widgets are in use at the same time; in that case, it makes sense to create different configs
to switch between. We can only pass one configuration to the initializer, but that just forwards it to the ``.add_to_config()``
method, which we can call as many times with different config objects as we like. The initializer must still receive a valid
config to serve as a beginner's trap safety net.
Here's a slightly more advanced example:
.. code-block:: python
from st3m.ui import widgets
import captouch
class App(Application):
def __init__(self, app_ctx):
super().__init__(app_ctx)
self.captouch_configs = [None, None]
for x in range(2):
# create a empty configuration
captouch_config = captouch.Config.empty()
# manually add petal 5 in button mode
captouch_config[5].mode = 1
self.captouch_configs[x] = captouch_config
# the slider widget is only sometimes in use:
self.slider_enabled = True
# add both widget requirements to configs[0]
self.slider = widgets.Slider(self.captouch_configs[0], 4)
self.scroller = widgets.Scroller(self.captouch_configs[0], 2)
# only add the scroller configs[1]:
self.scroller.add_to_config(self.captouch_configs[1])
self.widgets = [self.slider, self.scroller]
def on_enter(self, vm):
super().on_enter(vm)
# select config we want
config_index = 0 if self.slider_active else 1
self.captouch_configs[config_index].apply()
for widget in self.widgets:
if self.slider_enabled or widget != self.Slider:
widget.on_enter()
def on_exit(self):
super().on_exit()
# calling widget.on_exit() multiple times without pairing it with on_enter()
# is okay so we don't need to check here
for widget in self.widgets:
widget.on_exit()
def think(self, ins, delta_ms):
super().think(ins, delta_ms)
for widget in self.widgets:
widget.think(ins, delta_ms)
if self.input.captouch.petals[5].whole.pressed:
self.slider_enabled = not self.slider_enabled
if self.slider_enabled:
self.slider.on_enter()
else:
self.slider.on_exit()
config_index = 0 if self.slider_active else 1
self.captouch_configs[config_index].apply()
if self.slider_enabled:
do_thing_with_slider_value(self.slider.pos)
do_thing_with_scroller_value(scroller_value = self.scroller.pos)
With that out of the way, let's finally look at the base class of all captouch widgets:
.. py:class:: CaptouchWidget(Widget)
.. py:method:: __init__(config: CaptouchConfig, gain: complex = 1, constraint: Constraint = None, \
friction: float = 0.7, bounce: float = 1) -> None
Initializes widget and adds its requirements to ``config`` via ``.add_to_config()``.
This is mandatory as most widgets do not work with the default captouch driver configuration.
The other arguments initialize the parameters of the same names.
.. py:method:: add_to_config(config: CaptouchConfig) -> None
Adds the requirements of the widget to a captouch config object, see examples above.
.. py:attribute:: pos
:type: complex
Main data output of widget. Individual behavior is documented in the subclasses below. Default value is 0 unless specified
otherwise.
While this is primary intended for read access, writing is allowed. This is useful for example for initializing a Slider
to a default value or resetting a widget that does relative movements. Writing does not automatically apply the current
``.constraint`` but you can do so manually as with the "bad bounce" workaround (see Constraints section for more details).
.. py:attribute:: active
:type: bool
Read only: Whether the widget is currently interacted with or not. Updated by ``.think()``.
.. py:attribute:: gain
:type: complex
Multiplier that scales how much a physical captouch interaction changes the ``.pos`` output. Since it is complex-valued
this can also include a rotation around the origin, for example when ``cmath.rect(scaling_factor, angle)`` or
``scaling_factor * captouch.PETAL_ANGLES[petal_index]`` is used.
.. py:attribute:: constraint
:type: Constraint
Limits the possible values of ``.pos`` after ``.gain`` is applied. Note that swapping or modifying constraint after
widget initialization may result in un-"physical" side effects, see documentation of the ``Constraint`` class below.
Multiple widgets may use the same constraint, this results in the same behavior as individual identical constraints.
.. py:attribute:: friction
:type: float
How fast the speed of ``pos`` decays if it is moving freely. Must be positive or zero. Not used by all widgets.
.. py:attribute:: bounce
:type: float
By how much the absolute speed is multiplied when colliding with a constraint wall. Must be positive or zero.
Not used by all widgets.
Single-Petal Widgets
^^^^^^^^^^^^^^^^^^^^
.. py:class:: PetalWidget(CaptouchWidget)
Parent class of a widget that reacts to a single petal.
.. py:method:: __init__(config: CaptouchConfig, petal: int, **kwargs) -> None
Initializes widget and ties it to the petal with the index specified by the `petal` argument. ``config`` and ``**kwargs``
are forwarded to the initializer of ``CaptouchWidget``.
.. py:class:: Slider(PetalWidget)
This widget allows users to set an application parameter with absolute touch position. If no ``constraint`` argument
is passed to the initializer it defaults to a unit circle constraint. Ignores ``friction`` and ``bounce`` parameters.
.. py:attribute:: pos
:type: complex
Absolute position of the last touch.
.. py:class:: Scroller(PetalWidget)
This widget allows users to change an application parameter with relative touch position. If the friction is set to 1,
this can be used as an "incremental" ``Slider``, friction values between 0 and 1 allow for crude swipe gestures. *Note:
due to captouch data limitations it is very hard to not swipe at least a little bit, expect some residual drift when
planning a UI.*
If no ``constraint`` argument is passed or it is `None` to the initializer it throws a ``TypeError`` since else ``pos``
might grow without bounds up into the ``NaN`` range which would cause harder-to-detect rounding issues and crashes.
.. py:attribute:: pos
:type: complex
Relative position that is changed incrementally with touch.
Multi-Petal Widgets
^^^^^^^^^^^^^^^^^^^
.. py:class:: MultiSlider(CaptouchWidget)
If no ``constraint`` argument is passed to the initializer this defaults to a unit circle constraint. Ignores ``friction``
and ``bounce`` parameters.
.. py:attribute:: pos
:type: complex
Takes all top petals as one big surface. Active if only one touch is registered on that surface (i.e., if at most 2
adjacent petals are pressed). Normalized so that the absolute value is typically between 0.5 and 1. The center values
cannot be reached, but it is initialized to 0 anyways.
Constraints
^^^^^^^^^^^
Constraints limit the ``pos`` output of a ``CaptouchWidget`` to a region. This needn't be simple value clipping but can also include
more advanced behavior such as overflowing or bouncing off walls. Since you probably want different behavior depending on whether
you're "holding" the "ball" or not, and the asynchronous nature of the petal log may result in multiple states per think, these needed
to be integrated deeply into the widgets. They're on this weird state of complexity where it feels tempting to either go full game
engine (which would however be hard to justify effort) or radically simplifying them (which would however restrict some use cases).
We decided to keep them on this middle ground for now, which has some implications:
- Resizing/centering/rotating them during runtime may result in "bad bounces" if a widget is at a position that was previously within
the constraint but now is outside. As a mitigation call ``.apply_hold()`` manually to avoid such a bounce.
- Constraints can't give positional outputs a physical "shapeness" to collide with others asthey have no concept of mass or motion
of self.
- There is no computationally efficient way to combine them to create a "game level". If you aim for this it is best to make one
"giant constraint" which implements all the math from scratch.
.. py:class:: Constraint
.. py:method:: __init__(size: [float, complex] = 1, center: complex = 0j, rotation: float = 0)
Creates a new constraint located at the given center with given rotation. The parameters initialize the attributes
of the same name.
*Note: Not all constraint subclasses must accept these parameters, we merely grouped them up here since all
constraints we provide use them. If you implement your own application specific constraint feel free to use
arbitrary arguments for the initializer.*
.. py:method:: apply_hold(pos: complex) -> complex
Returns ``pos`` limited to whatever range the constraint defines.
Typically called automatically by the widget(s) using the constraint, manual calls are rarely needed.
.. py:method:: apply_free(pos: complex, vel: complex, bounce: float) -> (complex, complex)
Returns ``(pos, vel)`` tuple. Like apply_hold, but allows for bouncing off of walls.
Typically called automatically by the widget(s) using the constraint, manual calls are rarely needed. Not all
widgets call this method; at this point in time, only the ones that use ``friction`` do so. If you inherit from
this base class and do not define it, it defaults to limiting ``pos`` with ``.apply_hold()`` and leaving ``vel``
untouched.
.. py:attribute:: size
:type: complex
Linearily scales the size of the constraint. The real component must be greater than 0, the imaginary component
must be no less than 0, else setting will raise a ValueError. Setting it with a float or a complex number whose
imaginary component is 0 results in a "square-shaped" value, or ``size = complex(size.real, size.real)``.
*Note: Not all constraint subclasses must have this attribute, see* ``__init__`` *note. Changing this attribute at
runtime may result in "bad bounces", see above.*
.. py:attribute:: center
:type: complex
Shifts the center of the constraint.
*Note: Not all constraint subclasses must have this attribute, see* ``__init__`` *note. Changing this attribute at
runtime may result in "bad bounces", see above.*
.. py:attribute:: rotation
:type: float
Rotates the constraint in radians.
*Note: Not all constraint subclasses must have this attribute, see* ``__init__`` *note. Changing this attribute at
runtime may result in "bad bounces", see above.*
.. py:class:: Rectangle(Constraint)
A rectangular constraint. Size corresponds to side length.
.. py:class:: ModuloRectangle(Constraint)
Like ``Rectangle``, but values overflow, ``bounce`` is ignored.
.. py:class:: Ellipse(Constraint)
An elliptic constraint. Size corresponds to radii.
...@@ -50,6 +50,7 @@ User manual ...@@ -50,6 +50,7 @@ User manual
api/application.rst api/application.rst
api/input.rst api/input.rst
api/captouch.rst api/captouch.rst
api/widgets.rst
api/audio.rst api/audio.rst
api/badgelink.rst api/badgelink.rst
api/badgenet.rst api/badgenet.rst
......
...@@ -11,6 +11,9 @@ from .common.action_view import ActionView ...@@ -11,6 +11,9 @@ from .common.action_view import ActionView
from .common import utils from .common import utils
from .common import theme from .common import theme
import captouch
from st3m.ui import widgets
class Reader(ActionView): class Reader(ActionView):
path: str path: str
...@@ -21,11 +24,9 @@ class Reader(ActionView): ...@@ -21,11 +24,9 @@ class Reader(ActionView):
has_error: bool = False has_error: bool = False
is_media: bool = False is_media: bool = False
content: str content: str
viewport_offset = (0.0, 0.0)
zoom_enabled = False zoom_enabled = False
scroll_x: CapScrollController scroller: widgets.Scroller
scroll_y: CapScrollController
def __init__( def __init__(
self, self,
...@@ -35,12 +36,10 @@ class Reader(ActionView): ...@@ -35,12 +36,10 @@ class Reader(ActionView):
) -> None: ) -> None:
super().__init__() super().__init__()
self.scroll_x = CapScrollController()
self.scroll_y = CapScrollController()
self.path = path self.path = path
self.navigate = navigate self.navigate = navigate
self.update_path = update_path self.update_path = update_path
self.padding = 80
# TODO: Buffered reading? # TODO: Buffered reading?
if self._is_media(): if self._is_media():
...@@ -62,6 +61,18 @@ class Reader(ActionView): ...@@ -62,6 +61,18 @@ class Reader(ActionView):
self._update_actions() self._update_actions()
self.captouch_config = captouch.Config.empty()
for x in range(2, 10, 2):
self.captouch_config.petals[x].mode = 2
self.scroller = widgets.Scroller(
self.captouch_config,
2,
gain=35 * captouch.PETAL_ANGLES[2],
bounce=0.1,
constraint=widgets.constraints.Rectangle(),
)
def _update_actions(self): def _update_actions(self):
controls = not self.has_error controls = not self.has_error
...@@ -81,12 +92,16 @@ class Reader(ActionView): ...@@ -81,12 +92,16 @@ class Reader(ActionView):
self.actions = [ self.actions = [
None, None,
Action(icon="\ue8d5", label="Scroll Y", enabled=controls), Action(icon="\ue56b", label="Scroll", enabled=controls),
Action(icon="\ue8b6", label="Zoom", enabled=controls), Action(icon="\ue8b6", label="Zoom", enabled=controls),
Action(icon="\ue5c4", label="Back"), Action(icon="\ue5c4", label="Back"),
Action(icon="\ue8d4", label="Scroll X", enabled=controls), Action(icon="\ue5d6", label="Break", enabled=controls),
] ]
def on_enter(self, vm):
super().on_enter(vm)
self.captouch_config.apply()
def think(self, ins: InputState, delta_ms: int) -> None: def think(self, ins: InputState, delta_ms: int) -> None:
super().think(ins, delta_ms) super().think(ins, delta_ms)
...@@ -107,14 +122,9 @@ class Reader(ActionView): ...@@ -107,14 +122,9 @@ class Reader(ActionView):
self._update_actions() self._update_actions()
elif self.input.captouch.petals[8].whole.pressed: elif self.input.captouch.petals[8].whole.pressed:
media.seek(0) media.seek(0)
else:
# TODO: Use "joystick-style" input for scrolling self.scroller.friction = 1 if ins.captouch.petals[8].pressed else 0.9
if not self.is_media: self.scroller.think(ins, delta_ms)
self.scroll_x.update(self.input.captouch.petals[8].gesture, delta_ms)
self.scroll_y.update(self.input.captouch.petals[2].gesture, delta_ms)
x = self.scroll_x.position[0] * 0.2
y = self.scroll_y.position[0] * 0.2
self.viewport_offset = (x - 80, y - 80)
def draw(self, ctx: Context) -> None: def draw(self, ctx: Context) -> None:
utils.fill_screen(ctx, theme.BACKGROUND) utils.fill_screen(ctx, theme.BACKGROUND)
...@@ -177,12 +187,42 @@ class Reader(ActionView): ...@@ -177,12 +187,42 @@ class Reader(ActionView):
line_height = ctx.font_size line_height = ctx.font_size
x_offset = self.scroller.pos.real - self.padding
y_offset = self.scroller.pos.imag - self.padding
x_size = x_offset
for i, line in enumerate(self.content): for i, line in enumerate(self.content):
x, y = self.viewport_offset[0], self.viewport_offset[1] + i * line_height x, y = x_offset, y_offset + i * line_height
if y > 120 + line_height or y < -120 - line_height: if y < -120 - line_height:
continue continue
if y > 120 + line_height:
break
ctx.move_to(x, y) ctx.move_to(x, y)
if len(line) > 10240:
ctx.text(line[:10240]) ctx.text(line[:10240])
else:
ctx.text(line)
if ctx.x > x_size:
x_size = ctx.x
x_size -= x_offset
y_size = len(self.content) * line_height
padding = 30
x_size -= 2 * padding
y_size -= 2 * padding
if x_size <= 0:
x_size = 0.00001
if y_size <= 0:
y_size = 0.00001
size = complex(x_size, y_size)
constraint = self.scroller.constraint
constraint.size = size
center_diff = self.padding - padding
constraint.center = -size / 2 + complex(center_diff, center_diff)
# "bad bounce" workaround
self.scroller.pos = constraint.apply_hold(self.scroller.pos)
ctx.restore() ctx.restore()
...@@ -203,7 +243,7 @@ class Reader(ActionView): ...@@ -203,7 +243,7 @@ class Reader(ActionView):
def _read_file(self) -> None: def _read_file(self) -> None:
try: try:
with open(self.path, "r", encoding="utf-8") as f: with open(self.path, "r", encoding="utf-8") as f:
self.content = f.readlines() self.content = [line.rstrip("\n") for line in f.readlines()]
except: except:
self.has_error = True self.has_error = True
...@@ -225,3 +265,4 @@ class Reader(ActionView): ...@@ -225,3 +265,4 @@ class Reader(ActionView):
def on_exit(self): def on_exit(self):
if self.is_media: if self.is_media:
media.stop() media.stop()
self.captouch_config.apply_default()
...@@ -10,34 +10,7 @@ import math ...@@ -10,34 +10,7 @@ import math
import leds import leds
from st3m.application import Application, ApplicationContext from st3m.application import Application, ApplicationContext
from st3m.ui import widgets
def inclination(vec):
x, y, z = vec[0], vec[1], vec[2]
if z > 0:
return math.atan((((x**2) + (y**2)) ** 0.5) / z)
elif z < 0:
return math.tau / 2 + math.atan((((x**2) + (y**2)) ** 0.5) / z)
return math.tau / 4
def azimuth(vec):
x, y, z = vec[0], vec[1], vec[2]
if x > 0:
return math.atan(y / x)
elif x < 0:
if y < 0:
return math.atan(y / x) - math.tau / 2
else:
return math.atan(y / x) + math.tau / 2
elif y < 0:
return -math.tau / 4
return math.tau / 4
def relative_altitude(pascal, celsius):
# https://en.wikipedia.org/wiki/Hypsometric_equation
return (celsius + 273.15) * (287 / 9.81) * math.log(101325 / pascal, math.e)
class App(Application): class App(Application):
...@@ -47,170 +20,154 @@ class App(Application): ...@@ -47,170 +20,154 @@ class App(Application):
self.interval = 20 self.interval = 20
self.data_exists = False self.data_exists = False
self.rotate = 0 self.rotate = 0
self.alt_smooth = None
self.rot_velo = 0 self.rot_velo = 0
self.rot_mass = 2 self.rot_mass = 2
self.rot_friction = 0.93 self.rot_friction = 0.93
self.alt_slow_widget = widgets.Altimeter()
self.alt_fast_widget = widgets.Altimeter(filter_deg=0)
self.inc_widget = widgets.Inclinometer(buffer_len=1)
self.widgets = [self.alt_slow_widget, self.alt_fast_widget, self.inc_widget]
self.single_line_titles = [
"pressure",
"temperature",
"battery voltage",
"relative altitude",
]
self.double_line_titles = ["accelerometer", "gyroscope"]
self.units = ["m", "deg"]
self.units = [f"(x, y, z) [{x}/s]" for x in self.units]
def draw_background(self, ctx): def draw_background(self, ctx):
ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill() ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
offset = self.offset
interval = self.interval interval = self.interval
counter = self.offset
ctx.font_size = 25 ctx.font = "Camp Font 2"
ctx.font = ctx.get_font_name(5)
ctx.text_align = ctx.RIGHT ctx.text_align = ctx.RIGHT
ctx.rgb(0.8, 0.8, 0.6) ctx.rgb(0.8, 0.8, 0.6)
ctx.font_size = 22 ctx.font_size = 22
counter = offset
gap = -4 gap = -4
for x in self.single_line_titles:
ctx.move_to(gap, counter) ctx.move_to(gap, counter)
counter += interval counter += interval
ctx.text("pressure") ctx.text(x)
ctx.move_to(gap, counter)
counter += interval
ctx.text("temperature")
ctx.move_to(gap, counter)
counter += interval
ctx.text("battery voltage")
ctx.move_to(gap, counter)
counter += interval
ctx.text("relative altitude")
for x in self.double_line_titles:
ctx.move_to(gap, counter) ctx.move_to(gap, counter)
counter += 2 * interval counter += 2 * interval
ctx.text("accelerometer") ctx.text(x)
ctx.move_to(gap, counter)
counter += 2 * interval
ctx.text("gyroscope")
gap = 4 gap = 4
counter -= 4 * interval
ctx.text_align = ctx.LEFT ctx.text_align = ctx.LEFT
ctx.rgb(0.6, 0.7, 0.6) ctx.rgb(0.6, 0.7, 0.6)
ctx.font = ctx.get_font_name(3) ctx.font = "Arimo Bold Italic"
ctx.font_size = 14 ctx.font_size = 14
counter -= 4 * interval
for x in self.units:
ctx.move_to(gap, counter) ctx.move_to(gap, counter)
counter += 2 * interval counter += 2 * interval
ctx.text("(x, y, z) [m/s]") ctx.text(x)
ctx.move_to(gap, counter)
counter += 2 * interval
ctx.text("(x, y, z) [deg/s]")
def draw(self, ctx: Context) -> None: def draw(self, ctx: Context) -> None:
offset = self.offset azi = self.inc_widget.azimuth
interval = self.interval inc = self.inc_widget.inclination
if azi is not None and inc is not None:
damp = 0.69 delta = azi - self.rotate
if delta > math.pi:
if self.data_exists: delta -= math.tau
inc = inclination(self.acc) / 3.14 elif delta < -math.pi:
delta = azimuth(self.acc) - self.rotate delta += math.tau
if delta > 3.14: # normalize to 1
delta -= 6.28 delta /= math.pi
elif delta < -3.14: inc /= math.pi
delta += 6.28
delta /= 3.14 # normalize to 1
if delta > 0: if delta > 0:
self.rot_velo += delta * (1 - delta) * inc * (1 - inc) self.rot_velo += delta * (1 - delta) * inc * (1 - inc)
else: else:
delta = -delta delta = -delta
self.rot_velo -= delta * (1 - delta) * inc * (1 - inc) self.rot_velo -= delta * (1 - delta) * inc * (1 - inc)
self.rotate += self.rot_velo / self.rot_mass self.rotate += self.rot_velo / self.rot_mass
self.rotate = self.rotate % 6.28 self.rotate = self.rotate % math.tau
self.rot_velo *= self.rot_friction self.rot_velo *= self.rot_friction
ctx.rotate(-self.rotate) ctx.rotate(-self.rotate)
if self.draw_background_request > 0:
self.draw_background(ctx) self.draw_background(ctx)
# rotation introduced, disabling partial refresh
# self.draw_background_request -= 1
else:
ctx.rgb(0, 0, 0)
ctx.rectangle(0, -120, 120, 125).fill()
ctx.rectangle(-120, offset + 4 * interval + 5, 240, 20).fill()
ctx.rectangle(-120, offset + 6 * interval + 5, 240, 20).fill()
if not self.data_exists: if not self.data_exists:
return return
ctx.font = ctx.get_font_name(5) ctx.font = "Camp Font 2"
ctx.font_size = 25 ctx.font_size = 25
counter = offset
counter = self.offset
interval = self.interval
gap = 4 gap = 4
ctx.rgb(0.5, 0.8, 0.8) ctx.rgb(0.5, 0.8, 0.8)
single_lines = [
str(self.pressure / 100)[:6] + "hPa",
str(self.temperature)[:5] + "degC",
"n/a",
str(self.altitude)[:6] + "m",
]
if self.battery_voltage is not None:
single_lines[2] = str(self.battery_voltage)[:5] + "V"
for x in single_lines:
ctx.move_to(gap, counter) ctx.move_to(gap, counter)
counter += interval counter += interval
ctx.text(str(self.pressure / 100)[:6] + "hPa") ctx.text(x)
ctx.move_to(gap, counter)
counter += interval
ctx.text(str(self.temperature)[:5] + "degC")
ctx.move_to(gap, counter)
counter += interval
if self.battery_voltage is None:
ctx.text("n/a")
else:
ctx.text(str(self.battery_voltage)[:5] + "V")
ctx.move_to(gap, counter)
counter += interval
ctx.text(str(self.altitude)[:6] + "m")
counter += interval counter += interval
ctx.text_align = ctx.MIDDLE ctx.text_align = ctx.MIDDLE
double_lines = [
", ".join([str(y)[:4] for y in x]) for x in [self.acc, self.gyro]
]
for x in double_lines:
ctx.move_to(0, counter) ctx.move_to(0, counter)
counter += 2 * interval counter += 2 * interval
acc = ", ".join([str(x)[:4] for x in self.acc]) ctx.text(f"({x})")
ctx.text("(" + acc + ")")
ctx.move_to(0, counter)
counter += 2 * interval
gyro = ", ".join([str(x)[:4] for x in self.gyro])
ctx.text("(" + gyro + ")")
def think(self, ins: InputState, delta_ms: int) -> None: def think(self, ins: InputState, delta_ms: int) -> None:
super().think(ins, delta_ms) super().think(ins, delta_ms)
for widget in self.widgets:
widget.think(ins, delta_ms)
self.pressure = ins.imu.pressure self.pressure = ins.imu.pressure
self.battery_voltage = ins.battery_voltage self.battery_voltage = ins.battery_voltage
self.temperature = ins.temperature self.temperature = ins.temperature
self.acc = ins.imu.acc
self.gyro = ins.imu.gyro
self.altitude = self.alt_fast_widget.meters_above_sea
self.data_exists = True
# not using actual temperature here since keeping rel. altitude led_altitude = self.alt_slow_widget.meters_above_sea
# identical when going on a balcony outweighs the extra outdoors if led_altitude is not None:
# precision in our opinion hue = 6.28 * (led_altitude % 1.0)
self.altitude = relative_altitude(self.pressure, 15)
self.acc = tuple(ins.imu.acc)
self.gyro = tuple(ins.imu.gyro)
n = 4
damp = 0.69 # cool q bro
if self.alt_smooth is None:
self.alt_smooth = [self.altitude] * (n + 1)
else:
self.alt_smooth[0] = self.altitude
for i in range(n):
self.alt_smooth[i + 1] *= damp
self.alt_smooth[i + 1] += self.alt_smooth[i] * (1 - damp)
hue = 6.28 * (self.alt_smooth[n] % 1.0)
leds.set_all_rgb(*colours.hsv_to_rgb(hue, 1, 1)) leds.set_all_rgb(*colours.hsv_to_rgb(hue, 1, 1))
leds.update() leds.update()
self.data_exists = True
def on_enter(self, vm: Optional[ViewManager]) -> None: def on_enter(self, vm: Optional[ViewManager]) -> None:
super().on_enter(vm) super().on_enter(vm)
for widget in self.widgets:
widget.on_enter()
self.data_exists = False self.data_exists = False
self.alt_smooth = None
self.draw_background_request = 10 def on_exit(self):
super().on_exit()
for widget in self.widgets:
widget.on_exit()
# For running with `mpremote run`: # For running with `mpremote run`:
......
...@@ -4,6 +4,7 @@ import sys_buttons ...@@ -4,6 +4,7 @@ import sys_buttons
import captouch import captouch
import imu import imu
from st3m.power import Power from st3m.power import Power
from st3m.ui import widgets
power = Power() power = Power()
...@@ -315,22 +316,6 @@ class Touchable: ...@@ -315,22 +316,6 @@ class Touchable:
MOVED = TouchableState.MOVED MOVED = TouchableState.MOVED
ENDED = TouchableState.ENDED ENDED = TouchableState.ENDED
class Entry:
"""
A Touchable's log entry, containing some position measurement at some
timestamp.
"""
__slots__ = ["ts", "phi", "rad"]
def __init__(self, ts: int, phi: float, rad: float) -> None:
self.ts = ts
self.phi = phi
self.rad = rad
def __repr__(self) -> str:
return f"{self.ts}: ({self.rad}, {self.phi})"
class Gesture: class Gesture:
""" """
A simple captouch gesture, currently definined as a movement between two A simple captouch gesture, currently definined as a movement between two
...@@ -340,101 +325,62 @@ class Touchable: ...@@ -340,101 +325,62 @@ class Touchable:
the current state is the last measured position. the current state is the last measured position.
""" """
def __init__(self, start: "Touchable.Entry", end: "Touchable.Entry") -> None: def __init__(self, dis, vel):
self.start = start self._dis = dis
self.end = end self._vel = vel
@property @property
def distance(self) -> Tuple[float, float]: def distance(self) -> Tuple[float, float]:
""" """
Distance traveled by this gesture. Distance traveled by this gesture.
""" """
delta_rad = self.end.rad - self.start.rad return self._dis
delta_phi = self.end.phi - self.start.phi
return (delta_rad, delta_phi)
@property @property
def velocity(self) -> Tuple[float, float]: def velocity(self) -> Tuple[float, float]:
""" """
Velocity vector of this gesture. Velocity vector of this gesture.
""" """
delta_rad = self.end.rad - self.start.rad return self._vel
delta_phi = self.end.phi - self.start.phi
if self.end.ts == self.start.ts:
return (0, 0)
delta_s = (self.end.ts - self.start.ts) / 1000
return (delta_rad / delta_s, delta_phi / delta_s)
def __init__(self, pos: tuple[float, float] = (0.0, 0.0)) -> None:
# Entry log, used for filtering.
self._log: List[Touchable.Entry] = []
# What the beginning of the gesture is defined as. This is ampled a few
# entries into the log as the initial press stabilizes.
self._start: Optional[Touchable.Entry] = None
self._start_ts: int = 0
# Current and previous 'pressed' state from the petal, used to begin
# gesture tracking.
self._pressed = False
self._prev_pressed = self._pressed
def __init__(self, ix) -> None:
self._state = self.UP self._state = self.UP
self._dis = None
self._vel = 0j
self._ts_prev = 0
# If not nil, amount of update() calls to wait until the gesture has conf = captouch.Config.empty()
# been considered as started. This is part of the mechanism which self._scroller = widgets.Scroller(
# eliminates early parts of a gesture while the pressure on the sensor conf, ix, constraint=widgets.constraints.Ellipse()
# grows and the user's touch contact point changes. )
self._begin_wait: Optional[int] = None self._scroller.constraint = None
self._active_prev = False
self._last_ts: int = 0
def _append_entry(self, ts: int, petal: captouch.CaptouchPetalState) -> None:
"""
Append an Entry to the log based on a given CaptouchPetalState.
"""
(rad, phi) = petal.position
entry = self.Entry(ts, phi, rad)
self._log.append(entry)
overflow = len(self._log) - 10
if overflow > 0:
self._log = self._log[overflow:]
def _update(self, ts: int, petal: captouch.CaptouchPetalState) -> None: def _update(self, ts: int, petal: captouch.CaptouchPetalState) -> None:
""" """
Called when the Touchable is being processed by an InputController. Called when the Touchable is being processed by an InputController.
""" """
self._last_ts = ts self._scroller.think(None, ts - self._ts_prev, petal)
self._prev_pressed = self._pressed self._ts_prev = ts
self._pressed = petal.pressed
if not self._pressed: if self._scroller.active:
if not self._prev_pressed or self._start is None: if self._active_prev == self._scroller.active:
self._state = self.UP self._state = self.MOVED
self._start = None self._dis = self._scroller.pos
else: else:
self._state = self.ENDED
return
self._append_entry(ts, petal)
if not self._prev_pressed:
# Wait 5 samples until we consider the gesture started.
# TODO(q3k): do better than hardcoding this. Maybe use pressure data?
self._begin_wait = 5
elif self._begin_wait is not None:
self._begin_wait -= 1
if self._begin_wait < 0:
self._begin_wait = None
# Okay, the gesture has officially started.
self._state = self.BEGIN self._state = self.BEGIN
# Grab latest log entry as gesture start. self._vel = 0j
self._start = self._log[-1] self._dis = 0j
self._start_ts = ts
# Prune log.
self._log = self._log[-1:]
else: else:
self._state = self.MOVED if self._active_prev == self._scroller.active:
self._state = self.UP
self._dis = None
else:
self._state = self.ENDED
self._vel = self._scroller._vel
self._scroller.pos = 0j
self._scroller._vel = 0j
self._active_prev = self._scroller.active
def phase(self) -> TouchableState: def phase(self) -> TouchableState:
""" """
...@@ -443,36 +389,23 @@ class Touchable: ...@@ -443,36 +389,23 @@ class Touchable:
return self._state return self._state
def current_gesture(self) -> Optional[Gesture]: def current_gesture(self) -> Optional[Gesture]:
if self._start is None: if self._dis is None:
return None return None
dis = self._dis * 35000
assert self._start_ts is not None vel = self._vel * 35000 * 1000
delta_ms = self._last_ts - self._start_ts return self.Gesture(*[(p.real, p.imag) for p in [dis, vel]])
first = self._start
last = self._log[-1]
# If this gesture hasn't ended, grab last 5 log entries for average of
# current position. This filters out a bunch of noise.
if self.phase() != self.ENDED:
log = self._log[-5:]
phis = [el.phi for el in log]
rads = [el.rad for el in log]
phi_avg = sum(phis) / len(phis)
rad_avg = sum(rads) / len(rads)
last = self.Entry(last.ts, phi_avg, rad_avg)
return self.Gesture(first, last)
class PetalState: class PetalState:
def __init__(self, ix: int) -> None: def __init__(self, ix: int) -> None:
self.ix = ix self.ix = ix
self._whole = Pressable(False) self._whole = Pressable(False)
self._gesture = Touchable() self._gesture = Touchable(ix)
self._whole_updated = False self._whole_updated = False
self._gesture_updated = False self._gesture_updated = False
self._ts = None self._ts = None
self._petal = None self._petal = None
self._captouch_config_hack_required = True
def _update(self, ts: int, petal: captouch.CaptouchPetalState) -> None: def _update(self, ts: int, petal: captouch.CaptouchPetalState) -> None:
self._ts = ts self._ts = ts
...@@ -493,8 +426,19 @@ class PetalState: ...@@ -493,8 +426,19 @@ class PetalState:
return 0 return 0
return self._petal.pressure return self._petal.pressure
def _ignore_pressed(self) -> None:
self._whole._ignore_pressed()
self._captouch_config_hack_required = True
@property @property
def gesture(self): def gesture(self):
if self._captouch_config_hack_required:
conf = captouch.Config.current()
conf.petals[self.ix].logging = True
conf.apply()
self._captouch_config_hack_required = False
self._gesture_log_required_hack = True
if self._petal and not self._gesture_updated: if self._petal and not self._gesture_updated:
self._gesture._update(self._ts, self._petal) self._gesture._update(self._ts, self._petal)
self._gesture_updated = True self._gesture_updated = True
...@@ -530,7 +474,7 @@ class CaptouchState: ...@@ -530,7 +474,7 @@ class CaptouchState:
def _ignore_pressed(self) -> None: def _ignore_pressed(self) -> None:
for petal in self._petals: for petal in self._petals:
petal.whole._ignore_pressed() petal._ignore_pressed()
class TriSwitchState: class TriSwitchState:
...@@ -638,3 +582,4 @@ class InputController: ...@@ -638,3 +582,4 @@ class InputController:
""" """
self.captouch._ignore_pressed() self.captouch._ignore_pressed()
self.buttons._ignore_pressed() self.buttons._ignore_pressed()
# hacking this in here sowy
...@@ -192,23 +192,23 @@ class CapScrollController: ...@@ -192,23 +192,23 @@ class CapScrollController:
TODO(q3k): notching into predefined positions, for use in menus TODO(q3k): notching into predefined positions, for use in menus
""" """
def __init__(self) -> None: def __init__(self, dampening=0.99) -> None:
self.position = (0.0, 0.0) self.position = (0.0, 0.0)
self.momentum = (0.0, 0.0) self.momentum = (0.0, 0.0)
# Current timestamp. # Position when touch started.
self._ts = 0
# Position when touch started, and time at which touch started.
self._grab_start: Optional[Tuple[float, float]] = None self._grab_start: Optional[Tuple[float, float]] = None
self._grab_start_ms: Optional[int] = None self._damp = dampening
def reset(self):
self.position = (0.0, 0.0)
self.momentum = (0.0, 0.0)
def update(self, t: Touchable, delta_ms: int) -> None: def update(self, t: Touchable, delta_ms: int) -> None:
""" """
Call this in your think() method. Call this in your think() method.
""" """
self._ts += delta_ms
if t.phase() == t.BEGIN: if t.phase() == t.BEGIN:
self._grab_start = self.position self._grab_start = self.position
self._grab_start_ms = self._ts
self.momentum = (0.0, 0.0) self.momentum = (0.0, 0.0)
if t.phase() == t.MOVED and self._grab_start is not None: if t.phase() == t.MOVED and self._grab_start is not None:
...@@ -235,7 +235,7 @@ class CapScrollController: ...@@ -235,7 +235,7 @@ class CapScrollController:
rad_m, phi_m = self.momentum rad_m, phi_m = self.momentum
rad_p += rad_m / (1000 / delta_ms) rad_p += rad_m / (1000 / delta_ms)
phi_p += phi_m / (1000 / delta_ms) phi_p += phi_m / (1000 / delta_ms)
rad_m *= 0.99 rad_m *= self._damp
phi_m *= 0.99 phi_m *= self._damp
self.momentum = (rad_m, phi_m) self.momentum = (rad_m, phi_m)
self.position = (rad_p, phi_p) self.position = (rad_p, phi_p)
This diff is collapsed.
import math
import cmath
tai = math.tau * 1j
class Constraint:
def __init__(self, size=1.0, center=0j, rotation=0):
self.center = center
self.size = size
self.rotation = rotation
@property
def size(self):
return self._size
@property
def center(self):
return self._center
@property
def rotation(self):
return self._rotation
@size.setter
def size(self, val):
size = complex(val)
if size.imag < 0 or size.real <= 0:
raise ValueError(
f"size error: real part must be strictly positive, imaginary must be positive or 0. provided value: {size}"
)
if not size.imag:
size = complex(size.real, size.real)
self._size = size
self._resize = size
self._desize = complex(1 / size.real, 1 / size.imag)
@center.setter
def center(self, val):
self._center = complex(val)
@rotation.setter
def rotation(self, val):
self._rotation = val % math.tau
self._rerot = cmath.exp(tai * self._rotation)
self._derot = complex(self._rerot.real, -self._rerot.imag)
def apply_hold(self, pos):
return pos
def apply_free(self, pos, vel, bounce):
return self.apply_hold(pos), vel
def _transform(self, pos):
return self._transform_vel(pos - self._center)
def _detransform(self, pos):
return self._detransform_vel(pos) + self._center
def _transform_vel(self, vel):
# does not apply center
vel *= self._derot
vel = complex(self._desize.real * vel.real, self._desize.imag * vel.imag)
return vel
def _detransform_vel(self, vel):
# does not apply center
vel = complex(self._resize.real * vel.real, self._resize.imag * vel.imag)
vel *= self._rerot
return vel
class Rectangle(Constraint):
@staticmethod
def _complex2vec(c):
return [c.real, c.imag]
@staticmethod
def _vec2complex(v):
return complex(v[0], v[1])
def apply_hold(self, pos):
vecpos = self._complex2vec(self._transform(pos))
clip = False
for x in range(2):
if 0.5 < vecpos[x]:
clip = True
vecpos[x] = 0.5
elif -0.5 > vecpos[x]:
clip = True
vecpos[x] = -0.5
if clip:
pos = self._detransform(self._vec2complex(vecpos))
return pos
def apply_free(self, pos, vel, bounce):
if not bounce:
return self.apply_hold(pos), vel
vecpos = self._complex2vec(self._transform(pos))
vecvel = self._complex2vec(self._transform_vel(vel))
clip = False
for x in range(2):
vecpos[x] += 0.5
if not (0 <= vecpos[x] <= 1):
clip = True
hits, vecpos[x] = divmod(vecpos[x], 1)
if hits % 2:
vecpos[x] = 1 - vecpos[x]
if bounce == 1:
vecvel[x] = -vecvel[x]
if bounce != 1:
vecvel[x] *= (-bounce) ** abs(hits)
vecpos[x] -= 0.5
if clip:
pos = self._detransform(self._vec2complex(vecpos))
vel = self._detransform_vel(self._vec2complex(vecvel))
return pos, vel
class ModuloRectangle(Rectangle):
def apply_hold(self, pos):
pos_tf = self._transform(pos) + complex(0.5, 0.5)
clip = False
if not (1 >= pos_tf.imag >= 0):
pos_tf.imag %= 1
clip = True
if not (1 >= pos_tf.real >= 0):
pos_tf.real %= 1
clip = True
if clip:
pos = self._detransform(pos_tf - complex(0.5, 0.5))
return pos
def apply_free(self, pos, vel, bounce):
return self.apply_hold(pos), vel
class Ellipse(Constraint):
def apply_hold(self, pos):
pos_tf = self._transform(pos)
abs_sq = pos_tf.imag * pos_tf.imag + pos_tf.real * pos_tf.real
if abs_sq > 1:
pos_tf *= math.sqrt(1 / abs_sq)
pos = self._detransform(pos_tf)
return pos
def apply_free(self, pos, vel, bounce):
if not bounce:
return self.apply_hold(pos), vel
pos_tf = self._transform(pos)
vel_tf = self._transform_vel(vel)
timeout = 0
clip = False
while True:
abs_sq = pos_tf.imag * pos_tf.imag + pos_tf.real * pos_tf.real
if abs_sq <= 1:
break
clip = True
if not vel_tf or timeout > 100:
# arbirary iteration depth, if it's still outside of the circle (too fast?) we simply
# clip it and set the velocity to 0. there may be more elegant solutions to this.
pos_tf *= math.sqrt(1 / abs_sq)
vel_tf = 0j
break
pos_tf, vel_tf = self._apply_free_inner(abs_sq, pos_tf, vel_tf, bounce)
timeout += 1
if clip:
pos = self._detransform(pos_tf)
vel = self._detransform_vel(vel_tf)
return pos, vel
def _apply_free_inner(self, abs_sq, pos, vel, bounce):
# https://math.stackexchange.com/questions/228841/how-do-i-calculate-the-intersections-of-a-straight-line-and-a-circle
pos_prev = pos - vel
A = -vel.imag
B = vel.real
C = pos_prev.real * pos.imag - pos_prev.imag * pos.real
a = A * A + B * B
if abs(vel.real) > abs(vel.imag):
xy_is_x = True
b = 2 * A * C
c = C * C - B * B
else:
xy_is_x = False
b = 2 * B * C
c = C * C - A * A
root = b * b - 4 * a * c
if root > 0:
root = math.sqrt(root)
xy = [(-b + root) / (2 * a), (-b - root) / (2 * a)]
xy_pos = pos.real if xy_is_x else pos.imag
if abs(xy[0] - xy_pos) > abs(xy[1] - xy_pos):
xy = xy[1]
else:
xy = xy[0]
elif root == 0:
xy = -b / (2 * a)
else:
# velocity vector doesn't intersect with circle,
# shouldn't happen.
return pos * math.sqrt(1 / abs_sq), 0j
if xy_is_x:
gain = (xy - pos.real) / vel.real
y = pos.imag + gain * vel.imag
impact = xy + y * 1j
else:
gain = (xy - pos.imag) / vel.imag
x = pos.real + gain * vel.real
impact = x + xy * 1j
# we now know the impact point.
impact_vel = pos - impact
outpact_vel = -impact_vel / impact
outpact_vel -= 2j * outpact_vel.imag
outpact_vel *= impact
vel = abs(vel) * outpact_vel / abs(outpact_vel)
vel *= bounce
pos = impact + outpact_vel
return pos, vel
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment