/ NeoTrellis_M4_Grains_of_Sand / code.py
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