Add rounded-rect fill, arc stroke and clip-rect drawing primitives

The UI subsystem's render executor needs all three -- clay emits rectangles
with corner radii, per-side borders, and scissor commands -- but they earn
their place in draw.h on the same terms as everything else there: a rounded
panel, a ring gauge and a clipped viewport are things a game wants with or
without a layout engine.

akgl_draw_filled_rounded_rect decomposes into three band fills plus four
quarter-circle triangle fans through SDL_RenderGeometry, with the radius
clamped to half the shorter side. akgl_draw_arc strokes between an outer and
inner radius as one triangle strip, stepping in proportion to the swept angle
under a fixed vertex cap -- no VLAs, unlike clay's own SDL3 reference
renderer. akgl_draw_set_clip wraps SDL_SetRenderClipRect and is the one draw
entry point where a NULL rectangle is legal, because that is how a clip is
cleared.

Pixel-readback tests cover the corner geometry (inside the arc filled,
outside it untouched), the radius clamp, the zero-radius fall-through, ring
and quarter-arc coverage at mid-stroke, sweep bounds, and clip set/clear
round trips.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
2026-08-02 10:57:23 -04:00
parent b7ff54b09f
commit 064e6569e8
3 changed files with 507 additions and 0 deletions

View File

@@ -619,3 +619,229 @@ akerr_ErrorContext *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Fill one quarter-circle corner as a triangle fan.
*
* `SDL_RenderGeometry` colours from its vertices rather than the renderer's
* draw colour, so this neither pushes nor pops it.
*
* @param self The backend. Assumed non-`NULL` with a live `sdl_renderer`;
* the caller has already checked both.
* @param cx Horizontal position of the corner's centre -- the point the
* fan radiates from, one radius inside the rectangle.
* @param cy Vertical position of the centre.
* @param radius Radius of the quarter circle. Assumed positive.
* @param start_deg Angle the quarter starts at; it sweeps 90 degrees clockwise
* from there.
* @param color Colour to fill with.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKGL_ERR_SDL If the fan cannot be drawn.
*/
static akerr_ErrorContext *fill_corner_fan(akgl_RenderBackend *self, float32_t cx, float32_t cy, float32_t radius, float32_t start_deg, SDL_Color color)
{
SDL_Vertex vertices[AKGL_DRAW_ROUNDED_RECT_SEGMENTS + 2];
int indices[AKGL_DRAW_ROUNDED_RECT_SEGMENTS * 3];
SDL_FColor fcolor;
float32_t angle = 0.0f;
int i = 0;
PREPARE_ERROR(errctx);
fcolor.r = (float)color.r / 255.0f;
fcolor.g = (float)color.g / 255.0f;
fcolor.b = (float)color.b / 255.0f;
fcolor.a = (float)color.a / 255.0f;
vertices[0].position.x = cx;
vertices[0].position.y = cy;
vertices[0].color = fcolor;
vertices[0].tex_coord.x = 0.0f;
vertices[0].tex_coord.y = 0.0f;
for ( i = 0; i <= AKGL_DRAW_ROUNDED_RECT_SEGMENTS; i++ ) {
angle = (start_deg + ((90.0f / AKGL_DRAW_ROUNDED_RECT_SEGMENTS) * (float)i)) * (SDL_PI_F / 180.0f);
vertices[i + 1].position.x = cx + (radius * SDL_cosf(angle));
vertices[i + 1].position.y = cy + (radius * SDL_sinf(angle));
vertices[i + 1].color = fcolor;
vertices[i + 1].tex_coord.x = 0.0f;
vertices[i + 1].tex_coord.y = 0.0f;
}
for ( i = 0; i < AKGL_DRAW_ROUNDED_RECT_SEGMENTS; i++ ) {
indices[(i * 3) + 0] = 0;
indices[(i * 3) + 1] = i + 1;
indices[(i * 3) + 2] = i + 2;
}
FAIL_ZERO_RETURN(
errctx,
SDL_RenderGeometry(
self->sdl_renderer,
NULL,
vertices,
AKGL_DRAW_ROUNDED_RECT_SEGMENTS + 2,
indices,
AKGL_DRAW_ROUNDED_RECT_SEGMENTS * 3),
AKGL_ERR_SDL,
"%s",
SDL_GetError());
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_draw_filled_rounded_rect(akgl_RenderBackend *self, SDL_FRect *rect, float32_t radius, SDL_Color color)
{
SDL_Color previous;
SDL_FRect band;
float32_t half = 0.0f;
bool pushed = false;
bool drawfailed = false;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
FAIL_ZERO_RETURN(errctx, rect, AKERR_NULLPOINTER, "NULL rectangle");
if ( (rect->w <= 0.0f) || (rect->h <= 0.0f) ) {
SUCCEED_RETURN(errctx);
}
if ( radius <= 0.0f ) {
PASS(errctx, akgl_draw_filled_rect(self, rect, color));
SUCCEED_RETURN(errctx);
}
half = (((rect->w < rect->h) ? rect->w : rect->h) / 2.0f);
if ( radius > half ) {
radius = half;
}
ATTEMPT {
CATCH(errctx, push_draw_color(self, color, &previous));
pushed = true;
// The body is three bands: one the full width between the corner rows,
// and one between the corners along each of the top and bottom edges.
// A radius of exactly half the shorter side leaves one or more of them
// with no area, which SDL fills as nothing -- at the limit the whole
// shape is the four fans.
band.x = rect->x;
band.y = rect->y + radius;
band.w = rect->w;
band.h = rect->h - (radius * 2.0f);
if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) {
drawfailed = true;
}
band.x = rect->x + radius;
band.y = rect->y;
band.w = rect->w - (radius * 2.0f);
band.h = radius;
if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) {
drawfailed = true;
}
band.y = rect->y + rect->h - radius;
if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) {
drawfailed = true;
}
FAIL_NONZERO_BREAK(errctx, drawfailed, AKGL_ERR_SDL, "%s", SDL_GetError());
CATCH(errctx, fill_corner_fan(self, rect->x + radius, rect->y + radius, radius, 180.0f, color));
CATCH(errctx, fill_corner_fan(self, rect->x + rect->w - radius, rect->y + radius, radius, 270.0f, color));
CATCH(errctx, fill_corner_fan(self, rect->x + rect->w - radius, rect->y + rect->h - radius, radius, 0.0f, color));
CATCH(errctx, fill_corner_fan(self, rect->x + radius, rect->y + rect->h - radius, radius, 90.0f, color));
} CLEANUP {
if ( pushed == true ) {
IGNORE(pop_draw_color(self, &previous));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_draw_arc(akgl_RenderBackend *self, float32_t x, float32_t y, float32_t radius, float32_t start_deg, float32_t end_deg, float32_t thickness, SDL_Color color)
{
SDL_Vertex vertices[AKGL_DRAW_ARC_MAX_POINTS];
int indices[(AKGL_DRAW_ARC_MAX_POINTS - 2) * 3];
SDL_FColor fcolor;
float32_t span = 0.0f;
float32_t inner = 0.0f;
float32_t angle = 0.0f;
int segments = 0;
int base = 0;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
FAIL_NONZERO_RETURN(errctx, (radius <= 0.0f), AKERR_OUTOFBOUNDS, "Arc radius %f is not positive", (double)radius);
FAIL_NONZERO_RETURN(errctx, (thickness <= 0.0f), AKERR_OUTOFBOUNDS, "Arc thickness %f is not positive", (double)thickness);
span = end_deg - start_deg;
if ( span <= 0.0f ) {
SUCCEED_RETURN(errctx);
}
if ( span > 360.0f ) {
span = 360.0f;
}
inner = radius - thickness;
if ( inner < 0.0f ) {
inner = 0.0f;
}
// Steps in proportion to the angle swept, so a sliver of arc does not
// spend the whole vertex budget and a full circle uses all of it: the
// quarter-circle density of AKGL_DRAW_ROUNDED_RECT_SEGMENTS, capped by
// what AKGL_DRAW_ARC_MAX_POINTS vertices can carry at two per step.
segments = (int)((span / 90.0f) * (float)AKGL_DRAW_ROUNDED_RECT_SEGMENTS) + 1;
if ( segments > ((AKGL_DRAW_ARC_MAX_POINTS / 2) - 1) ) {
segments = (AKGL_DRAW_ARC_MAX_POINTS / 2) - 1;
}
fcolor.r = (float)color.r / 255.0f;
fcolor.g = (float)color.g / 255.0f;
fcolor.b = (float)color.b / 255.0f;
fcolor.a = (float)color.a / 255.0f;
for ( i = 0; i <= segments; i++ ) {
angle = (start_deg + ((span / (float)segments) * (float)i)) * (SDL_PI_F / 180.0f);
vertices[i * 2].position.x = x + (radius * SDL_cosf(angle));
vertices[i * 2].position.y = y + (radius * SDL_sinf(angle));
vertices[i * 2].color = fcolor;
vertices[i * 2].tex_coord.x = 0.0f;
vertices[i * 2].tex_coord.y = 0.0f;
vertices[(i * 2) + 1].position.x = x + (inner * SDL_cosf(angle));
vertices[(i * 2) + 1].position.y = y + (inner * SDL_sinf(angle));
vertices[(i * 2) + 1].color = fcolor;
vertices[(i * 2) + 1].tex_coord.x = 0.0f;
vertices[(i * 2) + 1].tex_coord.y = 0.0f;
}
for ( i = 0; i < segments; i++ ) {
base = i * 2;
indices[(i * 6) + 0] = base;
indices[(i * 6) + 1] = base + 2;
indices[(i * 6) + 2] = base + 1;
indices[(i * 6) + 3] = base + 1;
indices[(i * 6) + 4] = base + 2;
indices[(i * 6) + 5] = base + 3;
}
FAIL_ZERO_RETURN(
errctx,
SDL_RenderGeometry(
self->sdl_renderer,
NULL,
vertices,
(segments + 1) * 2,
indices,
segments * 6),
AKGL_ERR_SDL,
"%s",
SDL_GetError());
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_draw_set_clip(akgl_RenderBackend *self, SDL_Rect *rect)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
// NULL rect deliberately passes straight through: it is how the clip is
// cleared, and the header says so.
FAIL_ZERO_RETURN(
errctx,
SDL_SetRenderClipRect(self->sdl_renderer, rect),
AKGL_ERR_SDL,
"%s",
SDL_GetError());
SUCCEED_RETURN(errctx);
}