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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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}};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user