diff --git a/sim/README.md b/sim/README.md index af0a4b495580680d8caa646c30483b5543e22858..04a5e60ba783dd7bf5a916e579819306ea9179d3 100644 --- a/sim/README.md +++ b/sim/README.md @@ -32,7 +32,9 @@ python3 sim/run.py Known Issues --- -No support for input of any kind yet (captouch, three-way buttons). +Captouch petal input order is wrong. + +No support for three-way buttons yet. No support for audio yet. diff --git a/sim/fakes/ctx.py b/sim/fakes/ctx.py index 7e5ac6c8aeed9c19a8fb36144c1d362d32d50674..320ab11cf2982136f5265dd9d8e5f1ab20752211 100644 --- a/sim/fakes/ctx.py +++ b/sim/fakes/ctx.py @@ -48,9 +48,14 @@ class Wasm: Call ctx_new_for_framebuffer, but also first allocate the underlying framebuffer and return it alongside the Ctx*. """ - fb = self.malloc(width * height * 2) - print('fb', hex(fb)) - return fb, self._i.exports.ctx_new_for_framebuffer(fb, width, height, width * 2, 7) + fb = self.malloc(width * height * 4) + # Significant difference between on-device Ctx and simulation Ctx: we + # render to a BRGA8 (24bpp color + 8bpp alpha) buffer instead of 16bpp + # RGB565 like the device does. This allows us to directly blit the ctx + # framebuffer into pygame's surfaces, which is a _huge_ speed benefit + # (difference between ~10FPS and 500+FPS!). + BRGA8 = 5 + return fb, self._i.exports.ctx_new_for_framebuffer(fb, width, height, width * 4, BRGA8) def ctx_apply_transform(self, ctx, *args): args = [float(a) for a in args] @@ -91,7 +96,13 @@ class Ctx: return self def rgb(self, r, g, b): - self._emit(f"rgb {r/255} {g/255} {b/255}") + # TODO(q3k): dispatch by type instead of value, warn on + # ambiguous/unexpected values for type. + if r > 1.0 or g > 1.0 or b > 1.0: + r /= 255.0 + g /= 255.0 + b /= 255.0 + self._emit(f"rgb {r} {g} {b}") return self def text(self, s): diff --git a/sim/fakes/hardware.py b/sim/fakes/hardware.py index 68e3575fd89d9bb515a2ac8760bbf86f63d54505..dd65dc1e1917ef865ed5fc69f5710fd32b9b56ce 100644 --- a/sim/fakes/hardware.py +++ b/sim/fakes/hardware.py @@ -1,6 +1,9 @@ -import pygame import math import os +import time + +import pygame + pygame.init() screen_w = 814 @@ -10,6 +13,248 @@ simpath = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) bgpath = os.path.join(simpath, 'background.png') background = pygame.image.load(bgpath) + +class Simulation: + """ + Simulation implements the state and logic of the on-host pygame-based badge + simulator. + """ + + # Pixel coordinates of each petal 'marker'. + # TODO(q3k): document order + PETAL_POSITIONS = [ + (114, 302), (163, 112), (204, 587), ( 49, 477), (504, 587), (352, 696), (602, 298), (660, 477), (356, 122), (547, 117), + ] + # Size of each petal 'marker' rendered in overlay. + PETAL_MARKER_SIZE = 50 + + # Pixel coordinates of each LED. The order is the same as the hardware + # WS2812 chain, not the order as expected by the micropython API! + LED_POSITIONS = [ + (660, 455), (608, 490), (631, 554), (648, 618), (659, 690), (655, 770), (571, 746), (502, 711), + (452, 677), (401, 639), (352, 680), (299, 713), (241, 745), (151, 771), (147, 682), (160, 607), + (176, 549), (197, 491), (147, 453), ( 98, 416), ( 43, 360), ( 0, 292), ( 64, 267), (144, 252), + (210, 248), (276, 249), (295, 190), (318, 129), (351, 65), (404, 0), (456, 64), (490, 131), + (511, 186), (529, 250), (595, 247), (663, 250), (738, 264), (810, 292), (755, 371), (705, 419), + ] + + def __init__(self): + # Buffered LED state. Will be propagated to led_state when the + # simulated update_leds function gets called. + self.led_state_buf = [(0, 0, 0) for _ in self.LED_POSITIONS] + # Actual LED state as rendered. + self.led_state = [(0, 0, 0) for _ in self.LED_POSITIONS] + # Position of the simulation cursor in window pixel coordinates. + self.mouse_x, self.mouse_y = (0, 0) + # ID of petal being held (clicked), or None if no petal is being held. + self.petal_held = None + # ID of petal being hovered over, or None if no petal is being hovered + # over. + self.petal_hover = None + # Timestamp of last GUI render. Used by the lazy render GUI + # functionality. + self.last_gui_render = None + + # Surfaces for different parts of the simulator render. Some of them + # have a dirty bit which is an optimization to skip rendering the + # corresponding surface when there was no change to its render data. + self._led_surface = pygame.Surface((screen_w, screen_h), flags=pygame.SRCALPHA) + self._led_surface_dirty = True + self._petal_surface = pygame.Surface((screen_w, screen_h), flags=pygame.SRCALPHA) + self._petal_surface_dirty = True + self._full_surface = pygame.Surface((screen_w, screen_h), flags=pygame.SRCALPHA) + self._oled_surface = pygame.Surface((240, 240), flags=pygame.SRCALPHA) + + # Calculate OLED per-row offset. + # + # The OLED disc (240px diameter) will be written into a 240px x 240px + # axis-aligned bounding box. The rendering routine iterates over the + # bounding box row-per-row, and we only want to write that row's disc + # fragment for each row into the square bounding box. This fragment + # will be offset by some pixels from the left edge, and will be also + # shortened by the same count of pixels from the right edge. + # + # The way we calculate these offsets is quite naïve, but it's easy to + # reason about. First, we start off by calculating a 240x240px bitmask + # that is True if the pixel corresponding to this mask's bit is part of + # the OLED disc, and false otherwise. + mask = [ + [ + math.sqrt((x - 120)**2 + (y - 120)**2) <= 120 + for x in range(240) + ] + for y in range(240) + ] + # Now, we iterate the mask row-by-row and find the first True bit in + # it. The offset within that row is our per-row offset for the + # rendering routine. + self._oled_offset = [ + m.index(True) + for m in mask + ] + + def process_events(self): + """ + Process pygame events and update mouse_{x,y}, petal_held and + petal_hover. + """ + prev_petal_hover = self.petal_hover + evs = pygame.event.get() + for ev in evs: + if ev.type == pygame.MOUSEMOTION: + self.mouse_x, self.mouse_y = ev.pos + self.petal_hover = self._mouse_coords_to_petal_id() + if ev.type == pygame.MOUSEBUTTONDOWN: + self._process_mouse_down() + if ev.type == pygame.MOUSEBUTTONUP: + self._process_mouse_up() + + if prev_petal_hover != self.petal_hover: + self._petal_surface_dirty = True + + def _mouse_coords_to_petal_id(self): + if self.mouse_x is None or self.mouse_y is None: + return None + + for i, pos in enumerate(self.PETAL_POSITIONS): + (px, py) = pos + # TODO(q3k): pre-apply to PETAL_POSITIONS. + px += 50 + py += 50 + dx = self.mouse_x - px + dy = self.mouse_y - py + if math.sqrt(dx**2 + dy**2) < self.PETAL_MARKER_SIZE: + return i + return None + + def _process_mouse_up(self): + if self.petal_held is not None: + self.petal_held = None + self._petal_surface_dirty = True + + def _process_mouse_down(self): + if self.petal_held != self.petal_hover: + self.petal_held = self.petal_hover + self._petal_surface_dirty = True + + def _render_petal_markers(self, surface): + for i, pos in enumerate(self.PETAL_POSITIONS): + x, y = pos + # TODO(q3k): pre-apply to PETAL_POSITIONS + x += 50 + y += 50 + + if i == self.petal_held: + pygame.draw.circle(surface, (0x8b, 0x8b, 0x8b, 0x80), (x, y), 50) + elif i == self.petal_hover: + pygame.draw.circle(surface, (0x6b, 0x6b, 0x6b, 0xa0), (x, y), 50) + else: + pygame.draw.circle(surface, (0x5b, 0x5b, 0x5b, 0xa0), (x, y), 50) + + def _render_leds(self, surface): + for pos, state in zip(self.LED_POSITIONS, self.led_state): + # TODO(q3k): pre-apply to LED_POSITIONS + x = pos[0] + 3.0 + y = pos[1] + 3.0 + r, g, b = state + for i in range(20): + radius = 26 - i + r2 = r / (20 - i) + g2 = g / (20 - i) + b2 = b / (20 - i) + pygame.draw.circle(surface, (r2, g2, b2), (x, y), radius) + + def _render_oled(self, surface): + surface.fill((0, 0, 0, 0)) + buf = surface.get_buffer() + + fb = get_ctx()._get_fb() + fb = fb[:240*240*4] + for y in range(240): + # Use precalculated row offset to turn OLED disc into square + # bounded plane. + offset = self._oled_offset[y] + start_offs_bytes = y * 240 * 4 + start_offs_bytes += offset * 4 + end_offs_bytes = (y+1) * 240 * 4 + end_offs_bytes -= offset * 4 + buf.write(bytes(fb[start_offs_bytes:end_offs_bytes]), start_offs_bytes) + + def render_gui_now(self): + """ + Render the GUI elements, skipping overlay elements that aren't dirty. + + This does _not_ render the Ctx state into the OLED surface. For that, + call render_display. + """ + self.last_gui_render = time.time() + + full = self._full_surface + need_overlays = False + if self._led_surface_dirty or self._petal_surface_dirty: + full.fill((0, 0, 0, 255)) + full.blit(background, (0, 0)) + need_overlays = True + + if self._led_surface_dirty: + self._render_leds(self._led_surface) + self._led_surface_dirty = False + if need_overlays: + full.blit(self._led_surface, (0, 0), special_flags=pygame.BLEND_ADD) + + if self._petal_surface_dirty: + self._render_petal_markers(self._petal_surface) + self._petal_surface_dirty = False + if need_overlays: + full.blit(self._petal_surface, (0, 0)) + + # Always blit oled. Its' alpha blending is designed in a way that it + # can be repeatedly applied to a dirty _full_surface without artifacts. + center_x = 408 + center_y = 426 + off_x = center_x - (240 // 2) + off_y = center_y - (240 // 2) + full.blit(self._oled_surface, (off_x, off_y)) + + screen.blit(full, (0,0)) + pygame.display.flip() + + def render_gui_lazy(self): + """ + Render the GUI elements if needed to maintain a responsive 60fps of the + GUI itself. As with render_gui_now, the OLED surface is not rendered by + this call. + """ + target_fps = 60.0 + d = 1/target_fps + + if self.last_gui_render is None: + self.render_gui_now() + elif time.time() - self.last_gui_render > d: + self.render_gui_now() + + def render_display(self): + """ + Render the OLED surface from Ctx state. + + Afterwards, render_gui_{lazy,now} should still be called to actually + present the new OLED surface state to the user. + """ + self._render_oled(self._oled_surface) + + def set_led_rgb(self, ix, r, g, b): + self.led_state_buf[ix] = (r, g, b) + + def leds_update(self): + for i, s in enumerate(_sim.led_state_buf): + if _sim.led_state[i] != s: + _sim.led_state[i] = s + self._led_surface_dirty = True + + +_sim = Simulation() + + _deprecated_notified = set() def _deprecated(f): def wrapper(*args, **kwargs): @@ -19,15 +264,19 @@ def _deprecated(f): return f(*args, **kwargs) return wrapper + def init_done(): return True + def captouch_autocalib(): pass + def captouch_calibration_active(): return False + _global_ctx = None def get_ctx(): global _global_ctx @@ -37,6 +286,7 @@ def get_ctx(): _global_ctx = ctx.Ctx() return _global_ctx + @_deprecated def display_fill(color): """ @@ -51,67 +301,10 @@ def display_fill(color): def display_update(): - fb = get_ctx()._get_fb() - - full = pygame.Surface((screen_w, screen_h), flags=pygame.SRCALPHA) - full.fill((0, 0, 0, 255)) - full.blit(background, (0, 0)) - - leds = pygame.Surface((screen_w, screen_h), flags=pygame.SRCALPHA) - for pos, state in zip(leds_positions, leds_state): - x = pos[0] + 3.0 - y = pos[1] + 3.0 - r, g, b = state - for i in range(20): - radius = 26 - i - r2 = r / (20 - i) - g2 = g / (20 - i) - b2 = b / (20 - i) - pygame.draw.circle(leds, (r2, g2, b2), (x, y), radius) - full.blit(leds, (0, 0), special_flags=pygame.BLEND_ADD) - - center_x = 408 - center_y = 426 - off_x = center_x - (240 // 2) - off_y = center_y - (240 // 2) - - oled = pygame.Surface((240, 240), flags=pygame.SRCALPHA) - oled_buf = oled.get_buffer() - for y in range(240): - rgba = bytearray() - for x in range(240): - dx = (x - 120) - dy = (y - 120) - dist = math.sqrt(dx**2 + dy**2) - - fbh = fb[y * 240 * 2 + x * 2] - fbl = fb[y * 240 * 2 + x * 2 + 1] - fbv = (fbh << 8) | fbl - r = (fbv >> 11) & 0b11111 - g = (fbv >> 5) & 0b111111 - b = (fbv >> 0) & 0b11111 - if dist > 120: - rgba += bytes([255, 255, 255, 0]) - else: - rgba += bytes([b << 3, g << 2, r << 3, 0xff]) - oled_buf.write(bytes(rgba), y * 240 * 4) - - del oled_buf - full.blit(oled, (off_x, off_y)) - - screen.fill((0, 0, 0, 255)) - screen.blit(full, (0,0)) - pygame.display.flip() - -leds_positions = [ - (660, 455), (608, 490), (631, 554), (648, 618), (659, 690), (655, 770), (571, 746), (502, 711), - (452, 677), (401, 639), (352, 680), (299, 713), (241, 745), (151, 771), (147, 682), (160, 607), - (176, 549), (197, 491), (147, 453), ( 98, 416), ( 43, 360), ( 0, 292), ( 64, 267), (144, 252), - (210, 248), (276, 249), (295, 190), (318, 129), (351, 65), (404, 0), (456, 64), (490, 131), - (511, 186), (529, 250), (595, 247), (663, 250), (738, 264), (810, 292), (755, 371), (705, 419), -] -leds_state_buf = [(0, 0, 0) for _ in leds_positions] -leds_state = [(0, 0, 0) for _ in leds_positions] + _sim.process_events() + _sim.render_display() + _sim.render_gui_now() + def set_led_rgb(ix, r, g, b): ix = ((39-ix) + 1 + 32)%40; @@ -125,17 +318,34 @@ def set_led_rgb(ix, r, g, b): g = 255 if b > 255: b = 255 - leds_state_buf[ix] = (r, g, b) + _sim.set_led_rgb(ix, r, g, b) + + +def set_led_hsv(ix, h, s, v): + color = pygame.Color(0) + h /= 255.0 + color.hsva = (h, s, v, 1.0) + r, g, b = color.r, color.g, color.b + r *= 255 + g *= 255 + b *= 255 + _sim.set_led_rgb(ix, r, g, b) + def update_leds(): - for i, s in enumerate(leds_state_buf): - leds_state[i] = s + _sim.leds_update() + _sim.render_gui_lazy() + def set_global_volume_dB(a): pass + def get_button(a): return 0 + def get_captouch(a): - return 0 + _sim.process_events() + _sim.render_gui_lazy() + return _sim.petal_held == a