code.py
  1  # SPDX-FileCopyrightText: 2018 Limor Fried for Adafruit Industries
  2  #
  3  # SPDX-License-Identifier: MIT
  4  
  5  # Digital sand demo uses the accelerometer to move sand particles in a
  6  # realistic way.  Tilt the board to see the sand grains tumble around and light
  7  # up LEDs.  Based on the code created by Phil Burgess and Dave Astels, see:
  8  #   https://learn.adafruit.com/digital-sand-dotstar-circuitpython-edition/code
  9  #   https://learn.adafruit.com/animated-led-sand
 10  # Ported to NeoTrellis M4 by John Thurmond.
 11  #
 12  # The MIT License (MIT)
 13  #
 14  # Permission is hereby granted, free of charge, to any person obtaining a copy
 15  # of this software and associated documentation files (the "Software"), to deal
 16  # in the Software without restriction, including without limitation the rights
 17  # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 18  # copies of the Software, and to permit persons to whom the Software is
 19  # furnished to do so, subject to the following conditions:
 20  #
 21  # The above copyright notice and this permission notice shall be included in
 22  # all copies or substantial portions of the Software.
 23  #
 24  # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 25  # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 26  # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 27  # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 28  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 29  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 30  # THE SOFTWARE.
 31  
 32  import math
 33  import random
 34  import board
 35  import audioio
 36  import audiocore
 37  import busio
 38  from rainbowio import colorwheel
 39  import adafruit_trellism4
 40  import adafruit_adxl34x
 41  
 42  N_GRAINS = 8  # Number of grains of sand
 43  WIDTH = 8  # Display width in pixels
 44  HEIGHT = 4  # Display height in pixels
 45  NUMBER_PIXELS = WIDTH * HEIGHT
 46  MAX_FPS = 20  # Maximum redraw rate, frames/second
 47  MAX_X = WIDTH * 256 - 1
 48  MAX_Y = HEIGHT * 256 - 1
 49  
 50  class Grain:
 51      """A simple struct to hold position and velocity information
 52      for a single grain."""
 53  
 54      def __init__(self):
 55          """Initialize grain position and velocity."""
 56          self.x = 0
 57          self.y = 0
 58          self.vx = 0
 59          self.vy = 0
 60  
 61  grains = [Grain() for _ in range(N_GRAINS)]
 62  
 63  color = random.randint(1, 254) # Set a random color to start
 64  current_press = set() # Get ready for button presses
 65  
 66  # Set up Trellis and accelerometer
 67  trellis = adafruit_trellism4.TrellisM4Express(rotation=0)
 68  i2c = busio.I2C(board.ACCELEROMETER_SCL, board.ACCELEROMETER_SDA)
 69  sensor = adafruit_adxl34x.ADXL345(i2c)
 70  
 71  # Add tap detection - with a pretty hard tap
 72  sensor.enable_tap_detection(threshold=50)
 73  
 74  color_mode = 0
 75  
 76  oldidx = 0
 77  newidx = 0
 78  delta = 0
 79  newx = 0
 80  newy = 0
 81  
 82  occupied_bits = [False for _ in range(WIDTH * HEIGHT)]
 83  
 84  # Add Audio file...
 85  f = open("water-click.wav", "rb")
 86  wav = audiocore.WaveFile(f)
 87  print("%d channels, %d bits per sample, %d Hz sample rate " %
 88        (wav.channel_count, wav.bits_per_sample, wav.sample_rate))
 89  audio = audioio.AudioOut(board.A1)
 90  #audio.play(wav)
 91  
 92  def index_of_xy(x, y):
 93      """Convert an x/column and y/row into an index into
 94      a linear pixel array.
 95  
 96      :param int x: column value
 97      :param int y: row value
 98      """
 99      return (y >> 8) * WIDTH + (x >> 8)
100  
101  def already_present(limit, x, y):
102      """Check if a pixel is already used.
103  
104      :param int limit: the index into the grain array of
105      the grain being assigned a pixel Only grains already
106      allocated need to be checks against.
107      :param int x: proposed clumn value for the new grain
108      :param int y: proposed row valuse for the new grain
109      """
110      for j in range(limit):
111          if x == grains[j].x or y == grains[j].y:
112              return True
113      return False
114  
115  
116  for g in grains:
117      placed = False
118      while not placed:
119          g.x = random.randint(0, WIDTH * 256 - 1)
120          g.y = random.randint(0, HEIGHT * 256 - 1)
121          placed = not occupied_bits[index_of_xy(g.x, g.y)]
122      occupied_bits[index_of_xy(g.x, g.y)] = True
123      g.vx = 0
124      g.vy = 0
125  
126  while True:
127      # Check for tap and adjust color mode
128      if sensor.events['tap']:
129          color_mode += 1
130      if color_mode > 2:
131          color_mode = 0
132  
133      # Display frame rendered on prior pass.  It's done immediately after the
134      # FPS sync (rather than after rendering) for consistent animation timing.
135  
136      for i in range(NUMBER_PIXELS):
137  
138          # Some color options:
139  
140          # Random color every refresh
141          if color_mode == 0:
142              if occupied_bits[i]:
143                  trellis.pixels[(i%8, i//8)] = colorwheel(random.randint(1, 254))
144              else:
145                  trellis.pixels[(i%8, i//8)] = (0, 0, 0)
146          # Color by pixel
147          if color_mode == 1:
148              trellis.pixels[(i%8, i//8)] = colorwheel(i*2) if occupied_bits[i] else (0, 0, 0)
149  
150          # Change color to random on button press, or cycle when you hold one down
151          if color_mode == 2:
152              trellis.pixels[(i%8, i//8)] = colorwheel(color) if occupied_bits[i] else (0, 0, 0)
153  
154      # Change color to a new random color on button press
155      pressed = set(trellis.pressed_keys)
156      for press in pressed - current_press:
157          if press:
158              print("Pressed:", press)
159              color = random.randint(1, 254)
160              print("Color:", color)
161  
162      # Read accelerometer...
163      f_x, f_y, f_z = sensor.acceleration
164  
165      # I had to manually scale these to get them in the -128 to 128 range-ish - should be done better
166      f_x = int(f_x * 9.80665 * 16704/1000)
167      f_y = int(f_y * 9.80665 * 16704/1000)
168      f_z = int(f_z * 9.80665 * 16704/1000)
169  
170      ax = f_x >> 3  # Transform accelerometer axes
171      ay = f_y >> 3  # to grain coordinate space
172      az = abs(f_z) >> 6  # Random motion factor
173  
174      print("%6d %6d %6d"%(ax,ay,az))
175      az = 1 if (az >= 3) else (4 - az)  # Clip & invert
176      ax -= az  # Subtract motion factor from X, Y
177      ay -= az
178      az2 = (az << 1) + 1  # Range of random motion to add back in
179  
180      # Adjust axes for the NeoTrellis M4 (reuses code above rather than fixing it - inefficient)
181      ax2 = ax
182      ax = -ay
183      ay = ax2
184  
185      # ...and apply 2D accel vector to grain velocities...
186      v2 = 0  # Velocity squared
187      v = 0.0  # Absolute velociy
188      for g in grains:
189  
190          g.vx += ax + random.randint(0, az2)  # A little randomness makes
191          g.vy += ay + random.randint(0, az2)  # tall stacks topple better!
192  
193          # Terminal velocity (in any direction) is 256 units -- equal to
194          # 1 pixel -- which keeps moving grains from passing through each other
195          # and other such mayhem.  Though it takes some extra math, velocity is
196          # clipped as a 2D vector (not separately-limited X & Y) so that
197          # diagonal movement isn't faster
198  
199          v2 = g.vx * g.vx + g.vy * g.vy
200          if v2 > 65536:  # If v^2 > 65536, then v > 256
201              v = math.floor(math.sqrt(v2))  # Velocity vector magnitude
202              g.vx = (g.vx // v) << 8  # Maintain heading
203              g.vy = (g.vy // v) << 8  # Limit magnitude
204  
205      # ...then update position of each grain, one at a time, checking for
206      # collisions and having them react.  This really seems like it shouldn't
207      # work, as only one grain is considered at a time while the rest are
208      # regarded as stationary.  Yet this naive algorithm, taking many not-
209      # technically-quite-correct steps, and repeated quickly enough,
210      # visually integrates into something that somewhat resembles physics.
211      # (I'd initially tried implementing this as a bunch of concurrent and
212      # "realistic" elastic collisions among circular grains, but the
213      # calculations and volument of code quickly got out of hand for both
214      # the tiny 8-bit AVR microcontroller and my tiny dinosaur brain.)
215  
216      for g in grains:
217          newx = g.x + g.vx  # New position in grain space
218          newy = g.y + g.vy
219          if newx > MAX_X:  # If grain would go out of bounds
220              newx = MAX_X  # keep it inside, and
221              g.vx //= -2  # give a slight bounce off the wall
222          elif newx < 0:
223              newx = 0
224              g.vx //= -2
225          if newy > MAX_Y:
226              newy = MAX_Y
227              g.vy //= -2
228          elif newy < 0:
229              newy = 0
230              g.vy //= -2
231  
232          oldidx = index_of_xy(g.x, g.y)  # prior pixel
233          newidx = index_of_xy(newx, newy)  # new pixel
234          # If grain is moving to a new pixel...
235          if oldidx != newidx and occupied_bits[newidx]:
236              # but if that pixel is already occupied...
237              # What direction when blocked?
238              delta = abs(newidx - oldidx)
239              if delta == 1:  # 1 pixel left or right
240                  newx = g.x  # cancel x motion
241                  # and bounce X velocity (Y is ok)
242                  g.vx //= -2
243                  newidx = oldidx  # no pixel change
244              elif delta == WIDTH:  # 1 pixel up or down
245                  newy = g.y  # cancel Y motion
246                  # and bounce Y velocity (X is ok)
247                  g.vy //= -2
248                  newidx = oldidx  # no pixel change
249              else:  # Diagonal intersection is more tricky...
250                  # Try skidding along just one axis of motion if
251                  # possible (start w/ faster axis). Because we've
252                  # already established that diagonal (both-axis)
253                  # motion is occurring, moving on either axis alone
254                  # WILL change the pixel index, no need to check
255                  # that again.
256                  if abs(g.vx) > abs(g.vy):  # x axis is faster
257                      newidx = index_of_xy(newx, g.y)
258                      # that pixel is free, take it! But...
259                      if not occupied_bits[newidx]:
260                          newy = g.y  # cancel Y motion
261                          g.vy //= -2  # and bounce Y velocity
262                      else:  # X pixel is taken, so try Y...
263                          newidx = index_of_xy(g.x, newy)
264                          # Pixel is free, take it, but first...
265                          if not occupied_bits[newidx]:
266                              newx = g.x  # Cancel X motion
267                              g.vx //= -2  # Bounce X velocity
268                          else:  # both spots are occupied
269                              newx = g.x  # Cancel X & Y motion
270                              newy = g.y
271                              g.vx //= -2  # Bounce X & Y velocity
272                              g.vy //= -2
273                              newidx = oldidx  # Not moving
274                  else:  # y axis is faster. start there
275                      newidx = index_of_xy(g.x, newy)
276                      # Pixel's free! Take it! But...
277                      if not occupied_bits[newidx]:
278                          newx = g.x  # Cancel X motion
279                          g.vx //= -2  # Bounce X velocity
280                      else:  # Y pixel is taken, so try X...
281                          newidx = index_of_xy(newx, g.y)
282                          # Pixel is free, take it, but first...
283                          if not occupied_bits[newidx]:
284                              newy = g.y  # cancel Y motion
285                              g.vy //= -2  # and bounce Y velocity
286                          else:  # both spots are occupied
287                              newx = g.x  # Cancel X & Y motion
288                              newy = g.y
289                              g.vx //= -2  # Bounce X & Y velocity
290                              g.vy //= -2
291                              newidx = oldidx  # Not moving
292          occupied_bits[oldidx] = False
293          occupied_bits[newidx] = True
294          if oldidx != newidx:
295              audio.play(wav) # If there's an update, play the sound
296          g.x = newx
297          g.y = newy