diff --git a/firmware/application/ui/ui_geomap.cpp b/firmware/application/ui/ui_geomap.cpp index 230e8ef8a..4da7239b2 100644 --- a/firmware/application/ui/ui_geomap.cpp +++ b/firmware/application/ui/ui_geomap.cpp @@ -237,26 +237,69 @@ void GeoMap::map_read_line_bin(ui::Color* buffer, uint16_t pixels) { if (map_zoom == 1) { map_file.read(buffer, pixels << 1); } else if (map_zoom > 1) { - map_file.read(buffer, (pixels / map_zoom) << 1); - - // Zoom in: Expand each pixel to "map_zoom" number of pixels. - // Future TODO: Add dithering to smooth out the pixelation. - // As long as MOD(width,map_zoom)==0 then we don't need to check buffer overflow case when stretching last pixel; - // For 240 width, than means no check is needed for map_zoom values up to 6. - // (Rectangle height must also divide evenly into map_zoom or we get black lines at end of screen) - // Note that zooming in results in a map offset of (1/map_zoom) pixels to the right & downward directions (see zoom_pixel_offset). - for (int i = (width / map_zoom) - 1; i >= 0; i--) { - for (int j = 0; j < map_zoom; j++) { - buffer[(i * map_zoom) + j] = buffer[i]; + // Calculate how many source pixels we actually need from the file + uint16_t src_pixels_needed = (pixels + map_zoom - 1) / map_zoom; + if (src_pixels_needed == 0) src_pixels_needed = 1; + // Position the source data at the very END of the buffer. + // This allows us to overwrite the buffer from left-to-right safely. + uint16_t src_offset = pixels - src_pixels_needed; + // Read directly into the tail of the target buffer. Zero extra RAM needed. + map_file.read(&buffer[src_offset], src_pixels_needed << 1); + // Process forwards. Because `dst` grows by 1 and the source index grows by 1/map_zoom, + // `dst` will never catch up to overwrite a source pixel before we are done with it. + for (uint16_t dst = 0; dst < pixels; ++dst) { + uint16_t i0 = dst / map_zoom; + uint16_t i1 = i0 + 1; + // Clamp the right-hand pixel to avoid reading out of bounds on the last iteration + if (i1 >= src_pixels_needed) { + i1 = src_pixels_needed - 1; + } + // 'frac' represents the integer distance from the left pixel (0 to map_zoom - 1) + uint16_t frac = dst % map_zoom; + // Fetch colors from the tail of our buffer + ui::Color c0 = buffer[src_offset + i0]; + ui::Color c1 = buffer[src_offset + i1]; + if (frac == 0) { + // Exact pixel match, bypass the math entirely for a speed boost + buffer[dst] = c0; + } else { + uint32_t inv_frac = map_zoom - frac; + uint8_t r = ((uint32_t)c0.r() * inv_frac + (uint32_t)c1.r() * frac) / map_zoom; + uint8_t g = ((uint32_t)c0.g() * inv_frac + (uint32_t)c1.g() * frac) / map_zoom; + uint8_t b = ((uint32_t)c0.b() * inv_frac + (uint32_t)c1.b() * frac) / map_zoom; + buffer[dst] = ui::Color(r, g, b); } } } else { - ui::Color zoom_out_buffer[(pixels * (-map_zoom))]; - map_file.read(zoom_out_buffer, (pixels * (-map_zoom)) << 1); - // Zoom out: Collapse each group of "-map_zoom" pixels into one pixel. - // Future TODO: Average each group of pixels (in both X & Y directions if possible). - for (int i = 0; i < width; i++) { - buffer[i] = zoom_out_buffer[i * (-map_zoom)]; + const int skip = -map_zoom; + const int MAX_BUFFER_ELEMENTS = 256; + // Fixed-size local array avoids VLA crashes. + ui::Color zoom_out_buffer[MAX_BUFFER_ELEMENTS]; + const int total_elements_needed = pixels * skip; + // Use the default size, but strictly cap it at 256 to protect the stack + const int chunk_size = total_elements_needed < MAX_BUFFER_ELEMENTS ? total_elements_needed : MAX_BUFFER_ELEMENTS; + int target_i = 0; + int current_file_offset = 0; + // Sequentially read through the required portion of the file in chunks + while (target_i < width && current_file_offset < total_elements_needed) { + // Calculate how many pixels we can read in this specific pass + int read_size = chunk_size < (total_elements_needed - current_file_offset) ? chunk_size : (total_elements_needed - current_file_offset); + // Read the chunk (<< 1 converts pixel count to byte count) + map_file.read(zoom_out_buffer, read_size << 1); + // Determine where the first valid "zoomed" pixel is located inside this specific chunk + int first_valid_index = 0; + int offset_modulo = current_file_offset % skip; + if (offset_modulo != 0) { + first_valid_index = skip - offset_modulo; + } + // Extract only the pixels we need, skipping the rest + for (int j = first_valid_index; j < read_size; j += skip) { + if (target_i < width) { + buffer[target_i] = zoom_out_buffer[j]; + target_i++; + } + } + current_file_offset += read_size; } } } @@ -415,8 +458,7 @@ bool GeoMap::draw_osm_file(int zoom, int tile_x, int tile_y, int relative_x, int return true; } - BMPFile bmp{}; - bmp.open("/OSM/" + to_string_dec_int(zoom) + "/" + to_string_dec_int(tile_x) + "/" + to_string_dec_int(tile_y) + ".bmp", true); + BMPFile* bmp = bmp_cache.get(zoom, tile_x, tile_y); // 1. Define the source and destination areas, starting with the full tile. int src_x = 0; int src_y = 0; @@ -449,7 +491,7 @@ bool GeoMap::draw_osm_file(int zoom, int tile_x, int tile_y, int relative_x, int return true; } - if (!bmp.is_loaded()) { + if (!bmp || !bmp->is_loaded()) { // Draw an error rectangle using the calculated clipped dimensions ui::Rect error_rect{{dest_x + r.left(), dest_y + r.top()}, {clip_w, clip_h}}; display.fill_rectangle(error_rect, Theme::getInstance()->bg_darkest->background); @@ -458,20 +500,20 @@ bool GeoMap::draw_osm_file(int zoom, int tile_x, int tile_y, int relative_x, int map_line_buffer.resize(clip_w); - if (bmp.is_bottomup()) { + if (bmp->is_bottomup()) { for (int y = clip_h - 1; y >= 0; --y) { int source_row = src_y + y; int dest_row = dest_y + y; - bmp.seek(src_x, source_row); - bmp.read_next_px_cnt(map_line_buffer.data(), clip_w, false); + bmp->seek(src_x, source_row); + bmp->read_next_px_cnt(map_line_buffer.data(), clip_w, false); display.draw_pixels({dest_x + r.left(), dest_row + r.top(), clip_w, 1}, map_line_buffer); } } else { for (int y = 0; y < clip_h; ++y) { int source_row = src_y + y; int dest_row = dest_y + y; - bmp.seek(src_x, source_row); - bmp.read_next_px_cnt(map_line_buffer.data(), clip_w, false); + bmp->seek(src_x, source_row); + bmp->read_next_px_cnt(map_line_buffer.data(), clip_w, false); display.draw_pixels({dest_x + r.left(), dest_row + r.top(), clip_w, 1}, map_line_buffer); } } diff --git a/firmware/application/ui/ui_geomap.hpp b/firmware/application/ui/ui_geomap.hpp index 7122c8c49..5dfbbaa8e 100644 --- a/firmware/application/ui/ui_geomap.hpp +++ b/firmware/application/ui/ui_geomap.hpp @@ -30,13 +30,16 @@ #include "bmpfile.hpp" #include "mathdef.hpp" +#include +#include +#include #include "portapack.hpp" namespace ui { #define MAX_MAP_ZOOM_IN 4000 -#define MAX_MAP_ZOOM_OUT 10 -#define MAP_ZOOM_RESOLUTION_LIMIT 5 // Max zoom-in to show map; rect height & width must divide into this evenly +#define MAX_MAP_ZOOM_OUT 15 +#define MAP_ZOOM_RESOLUTION_LIMIT 10 // Max zoom-in to show map; #define INVALID_LAT_LON 200 #define INVALID_ANGLE 400 @@ -74,6 +77,101 @@ struct GeoMarker { } }; +class BMPFileCache { + public: + static constexpr uint8_t SlotsCount = 9; + + BMPFileCache() { + } + + ~BMPFileCache() { + clear(); + } + + BMPFile* get(const int32_t z, const int32_t x, const int32_t y) { + // Cache hit. + for (auto& slot : slots_) { + if (slot.used && slot.x == x && slot.y == y && slot.z == z) { + slot.last_used = next_stamp(); + return &slot.bmp; + } + } + + // Select free slot first, otherwise LRU slot. + Slot* target = nullptr; + uint16_t oldest = 0xFFFFu; + for (auto& slot : slots_) { + if (!slot.used) { + target = &slot; + break; + } + if (slot.last_used < oldest) { + oldest = slot.last_used; + target = &slot; + } + } + + if (!target) { + return nullptr; + } + + if (target->used) { + target->bmp.close(); + target->used = false; + } + + // OSM tile path convention: ///.bmp + char path_buffer[64]; + snprintf(path_buffer, sizeof(path_buffer), "/OSM/%" PRId32 "/%" PRId32 "/%" PRId32 ".bmp", z, x, y); + + if (!target->bmp.open(path_buffer, true)) { + target->bmp.close(); + return nullptr; + } + + target->x = x; + target->y = y; + target->z = z; + target->last_used = next_stamp(); + target->used = true; + return &target->bmp; + } + + void clear() { + for (auto& slot : slots_) { + if (slot.used) { + slot.bmp.close(); + slot.used = false; + } + } + } + + private: + struct Slot { + BMPFile bmp{}; + int32_t x{}; + int32_t y{}; + int32_t z{}; + uint16_t last_used{}; + bool used{false}; + }; + + uint16_t next_stamp() { + if (++stamp_ == 0) { + uint16_t v = 1; + for (auto& slot : slots_) { + if (slot.used) { + slot.last_used = v++; + } + } + stamp_ = v; + } + return stamp_; + } + std::array slots_{}; + uint16_t stamp_{0}; +}; + class GeoPos : public View { public: enum alt_unit { @@ -276,6 +374,7 @@ class GeoMap : public Widget { bool hide_center_marker_{false}; GeoMapMode mode_{}; File map_file{}; + BMPFileCache bmp_cache{}; bool map_opened{}; bool map_visible{}; uint16_t map_width{}, map_height{}; diff --git a/firmware/common/bmpfile.cpp b/firmware/common/bmpfile.cpp index edc6ac5ed..b2d8f78b0 100644 --- a/firmware/common/bmpfile.cpp +++ b/firmware/common/bmpfile.cpp @@ -105,6 +105,7 @@ bool BMPFile::open(const std::filesystem::path& file, bool readonly) { if (!((bmp_header.signature == 0x4D42) && (bmp_header.planes == 1) && (bmp_header.compression == 0 || bmp_header.compression == 3))) { + bmpimage.close(); return false; } @@ -114,6 +115,7 @@ bool BMPFile::open(const std::filesystem::path& file, bool readonly) { byte_per_px = 1; // bmpimage.seek(sizeof(bmp_header_t)); // bmpimage.read(color_palette, 1024); + bmpimage.close(); return false; // niy break; @@ -121,6 +123,7 @@ bool BMPFile::open(const std::filesystem::path& file, bool readonly) { byte_per_px = 2; type = 5; if (bmp_header.compression == 3) { + bmpimage.close(); return false; } // niy @@ -135,6 +138,7 @@ bool BMPFile::open(const std::filesystem::path& file, bool readonly) { break; default: // not supported + bmpimage.close(); return false; } byte_per_row = (bmp_header.width * byte_per_px + 3) & ~3;