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

@@ -628,6 +628,183 @@ akerr_ErrorContext *test_draw_background(void)
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_draw_filled_rounded_rect(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
SDL_FRect rect = { 8.0f, 8.0f, 40.0f, 24.0f };
SDL_FRect square = { 40.0f, 40.0f, 16.0f, 16.0f };
ATTEMPT {
CATCH(errctx, clear_target());
TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &rect, 8.0f, testred),
"filling a rounded rectangle");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(errctx, pixel_is(shot, 28, 20, testred),
"the middle of the rounded rectangle is unfilled");
TEST_ASSERT(errctx, pixel_is(shot, 28, 8, testred),
"the top edge between the corners is unfilled");
TEST_ASSERT(errctx, pixel_is(shot, 8, 20, testred),
"the left edge between the corners is unfilled");
// 12,12 is inside the corner's quarter circle (5.7px from its centre
// at 16,16); 8,8 and 9,9 are outside it (11.3px and 9.9px). The fan's
// triangles are chords of the circle, so they cover nothing beyond it.
TEST_ASSERT(errctx, pixel_is(shot, 12, 12, testred),
"the inside of the rounded corner is unfilled");
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testblack),
"the square corner pixel was filled despite the rounding");
TEST_ASSERT(errctx, pixel_is(shot, 9, 9, testblack),
"a pixel outside the corner arc was filled");
SDL_DestroySurface(shot);
shot = NULL;
// A radius beyond half the shorter side clamps to it, which on a
// square is a circle: centre filled, corners untouched.
CATCH(errctx, clear_target());
TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &square, 100.0f, testgreen),
"filling a rounded rectangle with an oversized radius");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(errctx, pixel_is(shot, 48, 48, testgreen),
"the centre of the clamped-radius square is unfilled");
TEST_ASSERT(errctx, pixel_is(shot, 41, 41, testblack),
"clamping the radius still filled the square's corner");
// Radius zero is the plain fill, corners and all.
CATCH(errctx, clear_target());
TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &rect, 0.0f, testred),
"a zero radius falling through to the plain fill");
SDL_DestroySurface(shot);
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testred),
"a zero radius did not fill the square corner");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_draw_filled_rounded_rect(NULL, &rect, 4.0f, testred),
"rounded fill through a NULL backend");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_draw_filled_rounded_rect(akgl_renderer, NULL, 4.0f, testred),
"rounded fill of a NULL rectangle");
} CLEANUP {
if ( shot != NULL ) {
SDL_DestroySurface(shot);
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_draw_arc(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
ATTEMPT {
// A full ring: outer radius 20, stroke 4, so mid-stroke is radius 18.
CATCH(errctx, clear_target());
TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 360.0f, 4.0f, testred),
"stroking a full circle");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(errctx, pixel_is(shot, 50, 32, testred),
"the ring is missing at three o'clock");
TEST_ASSERT(errctx, pixel_is(shot, 32, 50, testred),
"the ring is missing at six o'clock");
TEST_ASSERT(errctx, pixel_is(shot, 14, 32, testred),
"the ring is missing at nine o'clock");
TEST_ASSERT(errctx, pixel_is(shot, 32, 14, testred),
"the ring is missing at twelve o'clock");
TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testblack),
"the centre of the ring was filled");
TEST_ASSERT(errctx, pixel_is(shot, 42, 32, testblack),
"the stroke bled inside its inner radius");
SDL_DestroySurface(shot);
shot = NULL;
// A quarter arc sweeps clockwise from three o'clock to six o'clock;
// 44,44 is on its mid-stroke at 45 degrees, twelve o'clock is not in
// the sweep at all.
CATCH(errctx, clear_target());
TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, 4.0f, testgreen),
"stroking a quarter arc");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(errctx, pixel_is(shot, 44, 44, testgreen),
"the quarter arc is missing at its midpoint");
TEST_ASSERT(errctx, pixel_is(shot, 32, 14, testblack),
"the quarter arc drew outside its sweep");
// An empty sweep draws nothing and reports nothing.
TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 90.0f, 90.0f, 4.0f, testred),
"an arc with no sweep");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 0.0f, 0.0f, 90.0f, 4.0f, testred),
"an arc with no radius");
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, -1.0f, testred),
"an arc with a negative thickness");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_draw_arc(NULL, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, 4.0f, testred),
"an arc through a NULL backend");
} CLEANUP {
if ( shot != NULL ) {
SDL_DestroySurface(shot);
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *test_draw_set_clip(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
SDL_Rect clip = { 0, 0, 16, 16 };
SDL_FRect everything = { 0.0f, 0.0f, (float)TEST_TARGET_SIZE, (float)TEST_TARGET_SIZE };
ATTEMPT {
CATCH(errctx, clear_target());
TEST_EXPECT_OK(errctx, akgl_draw_set_clip(akgl_renderer, &clip),
"setting the clip rectangle");
TEST_EXPECT_OK(errctx, akgl_draw_filled_rect(akgl_renderer, &everything, testred),
"filling the whole target under a clip");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testred),
"the fill did not reach inside the clip rectangle");
TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testblack),
"the fill escaped the clip rectangle");
SDL_DestroySurface(shot);
shot = NULL;
TEST_EXPECT_OK(errctx, akgl_draw_set_clip(akgl_renderer, NULL),
"clearing the clip rectangle");
TEST_EXPECT_OK(errctx, akgl_draw_filled_rect(akgl_renderer, &everything, testgreen),
"filling the whole target after clearing the clip");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testgreen),
"clearing the clip did not restore the full target");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_draw_set_clip(NULL, &clip),
"clipping through a NULL backend");
} CLEANUP {
// The clip is renderer state; leaving it set would clip every test
// after this one.
IGNORE(akgl_draw_set_clip(akgl_renderer, NULL));
if ( shot != NULL ) {
SDL_DestroySurface(shot);
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
@@ -668,6 +845,9 @@ int main(void)
CATCH(errctx, test_draw_preserves_render_draw_color());
CATCH(errctx, test_draw_backend_without_a_renderer());
CATCH(errctx, test_draw_background());
CATCH(errctx, test_draw_filled_rounded_rect());
CATCH(errctx, test_draw_arc());
CATCH(errctx, test_draw_set_clip());
} CLEANUP {
SDL_Quit();
} PROCESS(errctx) {