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.
This commit is contained in:
2026-08-01 11:04:35 -04:00
parent 0a27062a70
commit 36953bb140
8 changed files with 186 additions and 48 deletions
+16 -1
View File
@@ -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
+8 -8
View File
@@ -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
+46
View File
@@ -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 <cmath>
#include <cstdint>
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<int>(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<uint16_t>((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<uint16_t>((r << 11) | (g << 5) | b);
}
} // namespace gfx
+41 -33
View File
@@ -4,6 +4,7 @@
#include <WiFi.h>
#include <esp_timer.h>
#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}};
+1
View File
@@ -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;
+72
View File
@@ -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 <cassert>
#include <cstdio>
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;
}
+1 -3
View File
@@ -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,
}
+1 -3
View File
@@ -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,
}