From 36953bb140f841bbd6b058b49090cbc1cd9a7d0c Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sat, 1 Aug 2026 11:04:35 -0400 Subject: [PATCH] fix(gui): anti-aliased rounded rects from one distance field Corners were drawn by two disagreeing algorithms: a hand-rolled per-row inset for the gradient fill and TFT_eSPI's Bresenham arc for the border, so they missed each other by a pixel and left a halo. The inset was also wrong, truncating the sqrt and ignoring pixel centres, which over-cut the top row by 2px into a visible chamfer. gui.roundRect now derives fill and border from the same signed distance, blending edge pixels by coverage, and replaces fillRoundRect, drawRoundRect and fillRectGradient. The geometry moved to src/gfx/round_rect.h so round_rect_test.cpp can check the arc on the host, since only the eye ever checked the old one. --- README.md | 17 ++++++++- sdcard/lib/ui.lua | 16 ++++----- src/gfx/round_rect.h | 46 +++++++++++++++++++++++++ src/lua/lua_app.cpp | 74 ++++++++++++++++++++++------------------ src/lua/lua_app.h | 1 + test/round_rect_test.cpp | 72 ++++++++++++++++++++++++++++++++++++++ test/ui_layout.lua | 4 +-- test/ui_theme.lua | 4 +-- 8 files changed, 186 insertions(+), 48 deletions(-) create mode 100644 src/gfx/round_rect.h create mode 100644 test/round_rect_test.cpp diff --git a/README.md b/README.md index 60ba42b..6912fe9 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Apps define optional callbacks: `setup()`, `draw()` (~30fps cap), `on_touch_down | Module | Functions | |---|---| -| `gui` | `width()`, `height()`, `clear(color)`, `fillRect(x,y,w,h,c)`, `drawRect(x,y,w,h,c)`, `fillCircle(x,y,r,c)`, `drawLine(x1,y1,x2,y2,c)`, `drawText(text,x,y,fg,bg)`, `fillRoundRect(x,y,w,h,r,c)`, `drawRoundRect(x,y,w,h,r,c)`, `fillRectGradient(x,y,w,h,r,top,bottom)`, `fontHeight()`, `textWidth(text)`, `setRotation(0-3)`, `color(r,g,b)` | +| `gui` | `width()`, `height()`, `clear(color)`, `fillRect(x,y,w,h,c)`, `drawRect(x,y,w,h,c)`, `fillCircle(x,y,r,c,bg)`, `drawLine(x1,y1,x2,y2,c)`, `drawText(text,x,y,fg,bg)`, `roundRect(x,y,w,h,radius,bg,top,bottom,border)`, `fontHeight()`, `textWidth(text)`, `setRotation(0-3)`, `color(r,g,b)` | | `input` | `getTouch()` -> `x,y` or nil, `getRawTouch()` -> raw ADC `x,y` or nil, `touched()` | | `fs` | `readFile(path)`, `writeFile(path, data)`, `exists(path)`, `listFiles(path)`, `listDirs(path)` | | `sys` | `millis()`, `exit()`, `launch(path)`, `setCalibration(x0,y0,x1,y1)`, `getRotation()`, `setRotation(deg)`, `getTheme()`, `setTheme(name)` | @@ -70,8 +70,23 @@ sideways on it. nix run nixpkgs#lua -- test/ui_layout.lua # layout, hit testing, capture nix run nixpkgs#lua -- test/ui_theme.lua # palette derivation and inheritance nix run nixpkgs#lua -- test/settings_calibration.lua # calibration math and menu flow + +# Rounded-rect geometry and blending, built straight from the shared header: +c++ -std=c++11 test/round_rect_test.cpp -o /tmp/round_rect_test && /tmp/round_rect_test ``` +## Drawing rounded surfaces + +`gui.roundRect(x, y, w, h, radius, bg, top, bottom, border)` draws a whole surface in +one pass. Fill and border come from the same signed distance field, so they cannot +disagree at the corners, and edge pixels are anti-aliased by coverage. `top`/`bottom` +are gradient stops (pass one for a solid, or `nil` for no fill) and `border` may be +`nil`. Since the panel has no alpha channel, `bg` is the colour underneath that edge +pixels blend into — pass the surface the shape sits on, not the shape's own fill. + +The geometry lives in `src/gfx/round_rect.h`, free of Arduino headers, because the +previous hand-rolled corner arc was wrong in a way only a pixel test would catch. + ## UI toolkit (`/lib/ui.lua`) Apps describe nesting and sizes fall out, borrowing CSS block flow and the box model diff --git a/sdcard/lib/ui.lua b/sdcard/lib/ui.lua index eeb394e..1281da7 100644 --- a/sdcard/lib/ui.lua +++ b/sdcard/lib/ui.lua @@ -256,18 +256,18 @@ function ui.button(spec) local node = component(spec) node.paint = function(self) local r = self.rect - local radius = self.radius or 6 - local fill = self.pressed and (self.press_bg or self.color) or (self.button_bg or self.bg) local gradient = self.pressed and self.press_gradient or (not self.pressed and self.gradient) + local top, bottom if gradient then - gui.fillRectGradient(r.x, r.y, r.w, r.h, radius, gradient[1], gradient[2]) - fill = gradient[2] -- text blends against the bottom stop - elseif fill then - gui.fillRoundRect(r.x, r.y, r.w, r.h, radius, fill) + top, bottom = gradient[1], gradient[2] + else + top = self.pressed and (self.press_bg or self.color) or self.button_bg + bottom = top end - gui.drawRoundRect(r.x, r.y, r.w, r.h, radius, self.color) + -- self.bg is the surface this button sits on, which the anti-aliased edge blends into. + gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.bg, top, bottom, self.color) for _, child in ipairs(self.children) do - child.bg = fill + child.bg = bottom or self.bg -- text blends against the bottom stop child.color = self.pressed and (self.press_color or self.bg) or self.color child.dirty = true end diff --git a/src/gfx/round_rect.h b/src/gfx/round_rect.h new file mode 100644 index 0000000..2e598df --- /dev/null +++ b/src/gfx/round_rect.h @@ -0,0 +1,46 @@ +#pragma once + +// Pure geometry and colour maths for the rounded-rect primitive, free of Arduino +// headers so test/round_rect_test.cpp can exercise it on the host. The previous +// corner arc was wrong for a year's worth of pixels because nothing but the eye +// ever checked it. + +#include +#include + +namespace gfx { + +// Signed distance to a rounded rectangle centred on the origin: negative inside, +// and near the edge its magnitude is the distance in pixels, so coverage falls out. +inline float roundRectDistance(float px, float py, float halfWidth, float halfHeight, float radius) { + float qx = std::fabs(px) - (halfWidth - radius); + float qy = std::fabs(py) - (halfHeight - radius); + float outsideX = std::fmax(qx, 0.0f), outsideY = std::fmax(qy, 0.0f); + return std::sqrt(outsideX * outsideX + outsideY * outsideY) + std::fmin(std::fmax(qx, qy), 0.0f) - radius; +} + +// Pixel centres sit half a unit inside their cell, which is what makes a straight +// edge land on exactly full coverage instead of a half-lit fringe. +inline float coverage(float distance) { + return std::fmin(std::fmax(0.5f - distance, 0.0f), 1.0f); +} + +inline uint16_t blend565(uint16_t under, uint16_t over, float amount) { + if (amount <= 0.0f) return under; + if (amount >= 1.0f) return over; + int a = static_cast(amount * 255.0f + 0.5f), inverse = 255 - a; + int r = (((over >> 11) & 0x1F) * a + ((under >> 11) & 0x1F) * inverse + 127) / 255; + int g = (((over >> 5) & 0x3F) * a + ((under >> 5) & 0x3F) * inverse + 127) / 255; + int b = ((over & 0x1F) * a + (under & 0x1F) * inverse + 127) / 255; + return static_cast((r << 11) | (g << 5) | b); +} + +inline uint16_t lerp565(uint16_t from, uint16_t to, int step, int steps) { + if (steps <= 0) return from; + int r = ((from >> 11) & 0x1F) + (((to >> 11) & 0x1F) - ((from >> 11) & 0x1F)) * step / steps; + int g = ((from >> 5) & 0x3F) + (((to >> 5) & 0x3F) - ((from >> 5) & 0x3F)) * step / steps; + int b = (from & 0x1F) + ((to & 0x1F) - (from & 0x1F)) * step / steps; + return static_cast((r << 11) | (g << 5) | b); +} + +} // namespace gfx diff --git a/src/lua/lua_app.cpp b/src/lua/lua_app.cpp index fb5c36c..f9db9b4 100644 --- a/src/lua/lua_app.cpp +++ b/src/lua/lua_app.cpp @@ -4,6 +4,7 @@ #include #include +#include "../gfx/round_rect.h" #include "../settings.h" extern "C" { @@ -57,9 +58,11 @@ static int l_gui_drawRect(lua_State* L) { return 0; } +// Anti-aliased, so it needs the surface behind it to blend the rim against. static int l_gui_fillCircle(lua_State* L) { - app(L)->tft.fillCircle(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), - luaL_checkinteger(L, 4)); + app(L)->tft.fillSmoothCircle(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), + luaL_checkinteger(L, 3), luaL_checkinteger(L, 4), + luaL_optinteger(L, 5, TFT_WHITE)); return 0; } @@ -69,40 +72,47 @@ static int l_gui_drawLine(lua_State* L) { return 0; } -static int l_gui_fillRoundRect(lua_State* L) { - app(L)->tft.fillRoundRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), - luaL_checkinteger(L, 4), luaL_checkinteger(L, 5), luaL_checkinteger(L, 6)); - return 0; -} - -static int l_gui_drawRoundRect(lua_State* L) { - app(L)->tft.drawRoundRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), - luaL_checkinteger(L, 4), luaL_checkinteger(L, 5), luaL_checkinteger(L, 6)); - return 0; -} - -// Interpolating in RGB565 keeps this a pure integer loop; a rounded corner just -// insets the span, so gradient buttons need no separate corner fill. -static int l_gui_fillRectGradient(lua_State* L) { +// One primitive draws the whole surface: fill (solid or vertical gradient) and border +// derive from the same distance field, so they cannot disagree at the corners the way +// two separate rounded-rect algorithms did. The panel has no alpha, so edge pixels are +// blended against `bg`, the colour of the surface underneath. +static int l_gui_roundRect(lua_State* L) { int x = luaL_checkinteger(L, 1), y = luaL_checkinteger(L, 2); int w = luaL_checkinteger(L, 3), h = luaL_checkinteger(L, 4); - int radius = luaL_checkinteger(L, 5); - uint16_t top = luaL_checkinteger(L, 6), bottom = luaL_checkinteger(L, 7); - if (w <= 0 || h <= 0) return 0; + float radius = luaL_checkinteger(L, 5); + uint16_t bg = luaL_checkinteger(L, 6); + bool hasFill = !lua_isnoneornil(L, 7); + bool hasBorder = !lua_isnoneornil(L, 9); + uint16_t top = hasFill ? luaL_checkinteger(L, 7) : 0; + uint16_t bottom = lua_isnoneornil(L, 8) ? top : luaL_checkinteger(L, 8); + uint16_t border = hasBorder ? luaL_checkinteger(L, 9) : 0; + if (w <= 0 || h <= 0 || w > LuaApp::MAX_SPAN) return 0; + radius = constrain(radius, 0.0f, min(w, h) / 2.0f); - int r1 = top >> 11, g1 = (top >> 5) & 0x3F, b1 = top & 0x1F; - int r2 = bottom >> 11, g2 = (bottom >> 5) & 0x3F, b2 = bottom & 0x1F; - radius = constrain(radius, 0, min(w, h) / 2); + float halfWidth = w * 0.5f, halfHeight = h * 0.5f; + static uint16_t span[LuaApp::MAX_SPAN]; + + // pushImage sends the buffer verbatim, but the panel wants each colour big-endian. + bool previousSwap = app(L)->tft.getSwapBytes(); + app(L)->tft.setSwapBytes(true); for (int row = 0; row < h; row++) { - uint16_t color = ((r1 + (r2 - r1) * row / (h - 1)) << 11) | - ((g1 + (g2 - g1) * row / (h - 1)) << 5) | - (b1 + (b2 - b1) * row / (h - 1)); - int inset = 0; - int edge = row < radius ? radius - row : (row >= h - radius ? row - (h - radius - 1) : 0); - if (edge > 0) inset = radius - (int)sqrtf((float)(radius * radius - edge * edge)); - app(L)->tft.drawFastHLine(x + inset, y + row, w - 2 * inset, color); + uint16_t fill = hasFill ? gfx::lerp565(top, bottom, row, h - 1) : 0; + float py = row + 0.5f - halfHeight; + for (int column = 0; column < w; column++) { + float distance = + gfx::roundRectDistance(column + 0.5f - halfWidth, py, halfWidth, halfHeight, radius); + float outer = gfx::coverage(distance); + // The border is the ring between the shape and the same shape inset by its width. + float inner = hasBorder ? gfx::coverage(distance + 1.0f) : outer; + uint16_t pixel = bg; + if (hasFill) pixel = gfx::blend565(pixel, fill, inner); + if (hasBorder) pixel = gfx::blend565(pixel, border, outer - inner); + span[column] = pixel; + } + app(L)->tft.pushImage(x, y + row, w, 1, span); } + app(L)->tft.setSwapBytes(previousSwap); return 0; } @@ -601,9 +611,7 @@ void LuaApp::registerBindings() { {"fillRect", l_gui_fillRect}, {"drawRect", l_gui_drawRect}, {"fillCircle", l_gui_fillCircle}, {"drawLine", l_gui_drawLine}, {"drawText", l_gui_drawText}, {"setRotation", l_gui_setRotation}, {"color", l_gui_color}, - {"fillRoundRect", l_gui_fillRoundRect}, - {"drawRoundRect", l_gui_drawRoundRect}, - {"fillRectGradient", l_gui_fillRectGradient}, + {"roundRect", l_gui_roundRect}, {"fontHeight", l_gui_fontHeight}, {"textWidth", l_gui_textWidth}, {nullptr, nullptr}}; diff --git a/src/lua/lua_app.h b/src/lua/lua_app.h index 0ecc135..ddb7c38 100644 --- a/src/lua/lua_app.h +++ b/src/lua/lua_app.h @@ -32,6 +32,7 @@ class LuaApp { static void mapTouch(TFT_eSPI& tft, const TS_Point& p, int16_t& x, int16_t& y); static constexpr size_t READ_CAP = 64 * 1024; + static constexpr int MAX_SPAN = 480; // longest panel edge, so one row buffer covers any shape private: lua_State* state = nullptr; diff --git a/test/round_rect_test.cpp b/test/round_rect_test.cpp new file mode 100644 index 0000000..5e66233 --- /dev/null +++ b/test/round_rect_test.cpp @@ -0,0 +1,72 @@ +// Build & run: c++ -std=c++17 test/round_rect_test.cpp -o /tmp/round_rect_test && /tmp/round_rect_test +#include "../src/gfx/round_rect.h" + +#include +#include + +using namespace gfx; + +static float cornerCoverage(int w, int h, float radius, int column, int row) { + float halfWidth = w * 0.5f, halfHeight = h * 0.5f; + return coverage(roundRectDistance(column + 0.5f - halfWidth, row + 0.5f - halfHeight, + halfWidth, halfHeight, radius)); +} + +int main() { + const int w = 296, h = 24; + const float radius = 6; + + // A straight edge is pixel aligned, so it must be fully lit; a fringe there would + // show up as a washed-out border down the sides of every button. + assert(cornerCoverage(w, h, radius, 0, h / 2) == 1.0f); + assert(cornerCoverage(w, h, radius, w - 1, h / 2) == 1.0f); + assert(cornerCoverage(w, h, radius, w / 2, 0) == 1.0f); + + // Well inside is solid, well outside the corner is empty. + assert(cornerCoverage(w, h, radius, w / 2, h / 2) == 1.0f); + assert(cornerCoverage(w, h, radius, 0, 0) == 0.0f); + + // The corner is a real arc: partial pixels exist, and coverage grows monotonically + // along the diagonal instead of stepping like the old integer inset did. + bool sawPartial = false; + float previous = -1.0f; + for (int i = 0; i < (int)radius; i++) { + float value = cornerCoverage(w, h, radius, i, i); + if (value > 0.0f && value < 1.0f) sawPartial = true; + assert(value >= previous); + previous = value; + } + assert(sawPartial && "an anti-aliased corner must produce partial coverage"); + + // The arc must stay within the radius: the pixel just past it is already solid. + assert(cornerCoverage(w, h, radius, (int)radius, (int)radius) == 1.0f); + + // Symmetry across both axes, so no corner is fatter than another. + for (int row = 0; row < (int)radius; row++) { + for (int column = 0; column < (int)radius; column++) { + float topLeft = cornerCoverage(w, h, radius, column, row); + assert(topLeft == cornerCoverage(w, h, radius, w - 1 - column, row)); + assert(topLeft == cornerCoverage(w, h, radius, column, h - 1 - row)); + assert(topLeft == cornerCoverage(w, h, radius, w - 1 - column, h - 1 - row)); + } + } + + // radius 0 keeps square corners fully lit, which is what the mono theme pins. + assert(cornerCoverage(w, h, 0, 0, 0) == 1.0f); + + // Blending endpoints must be exact, or repeated repaints would drift the colour. + const uint16_t black = 0x0000, white = 0xFFFF; + assert(blend565(black, white, 0.0f) == black); + assert(blend565(black, white, 1.0f) == white); + uint16_t half = blend565(black, white, 0.5f); + assert(half > black && half < white); + assert(blend565(white, white, 0.5f) == white); + + // A gradient must reach both stops exactly, so a button's edges match its theme. + assert(lerp565(black, white, 0, h - 1) == black); + assert(lerp565(black, white, h - 1, h - 1) == white); + assert(lerp565(black, white, 1, 0) == black); // degenerate height must not divide by zero + + printf("ok\n"); + return 0; +} diff --git a/test/ui_layout.lua b/test/ui_layout.lua index 08b6af2..bb02a9a 100644 --- a/test/ui_layout.lua +++ b/test/ui_layout.lua @@ -13,9 +13,7 @@ gui = { clear = function() end, fillRect = function() end, drawText = function(label, x, y) painted[#painted + 1] = {label = label, x = x, y = y} end, - fillRoundRect = function() end, - drawRoundRect = function() end, - fillRectGradient = function() end, + roundRect = function() end, fontHeight = function() return FONT_HEIGHT end, textWidth = function(text) return #text * CHAR_WIDTH end, } diff --git a/test/ui_theme.lua b/test/ui_theme.lua index db8935f..9eeba52 100644 --- a/test/ui_theme.lua +++ b/test/ui_theme.lua @@ -12,9 +12,7 @@ gui = { clear = function() end, fillRect = function() end, drawText = function() end, - fillRoundRect = function() end, - drawRoundRect = function() end, - fillRectGradient = function() end, + roundRect = function() end, fontHeight = function() return 8 end, textWidth = function(text) return #text * 6 end, }