//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // Regression test for issue #51: FontAtlas::Update used to re-upload the // whole 1024×1024 atlas every time a single glyph was rasterized. It now // accumulates a dirty bounding box (FontAtlas::DirtyRect) and copies only // that sub-rect. The bookkeeping is pure CPU math, so this test drives it // directly — no Vulkan device or WebGPU context needed: // // - a fresh rect is empty; the first Add seeds exact bounds // - further Adds grow the half-open union, never shrink it // - degenerate glyphs (w<=0 / h<=0) leave the rect untouched // - UploadBox converts (min,max) → (origin, extent) and clamps to the // atlas, and reports emptiness so Update can early-out // - FontAtlas::MarkDirty keeps the public `dirty` flag in lockstep with // the rect, and Reset() clears both notions of dirtiness // // The actual GPU copy params (bufferOffset / bufferRowLength on Vulkan, // dst origin / bytesPerRow on WebGPU) are exercised end-to-end by rendering // text in the HelloUI example on both backends. #include import Crafter.Graphics; import std; using namespace Crafter; namespace { int failures = 0; void Check(bool ok, std::string_view what) { std::println("{} {}", ok ? "PASS" : "FAIL", what); if (!ok) ++failures; } // Read UploadBox into locals and compare against an expected (x,y,w,h). bool BoxIs(const FontAtlas::DirtyRect& r, int limit, std::uint32_t ex, std::uint32_t ey, std::uint32_t ew, std::uint32_t eh) { std::uint32_t x = 99999, y = 99999, w = 99999, h = 99999; if (!r.UploadBox(limit, x, y, w, h)) return false; return x == ex && y == ey && w == ew && h == eh; } } // namespace int main() { // --- DirtyRect: empty / seed / grow ------------------------------------ { FontAtlas::DirtyRect r; Check(r.Empty(), "fresh rect is empty"); std::uint32_t x, y, w, h; Check(!r.UploadBox(1024, x, y, w, h), "UploadBox on empty rect returns false"); r.Add(10, 20, 4, 6); Check(!r.Empty(), "rect is non-empty after first Add"); Check(BoxIs(r, 1024, 10, 20, 4, 6), "first Add seeds exact (origin, extent)"); // A box fully inside the current one must not change anything. r.Add(11, 21, 1, 1); Check(BoxIs(r, 1024, 10, 20, 4, 6), "contained Add does not shrink or move the box"); // Grow up-left and down-right; result is the union. r.Add(5, 8, 2, 2); // extends min corner to (5,8) r.Add(100, 200, 10, 10); // extends max corner to (110,210) Check(BoxIs(r, 1024, 5, 8, 105, 202), "Adds grow to the half-open union"); } // --- DirtyRect: degenerate glyphs are no-ops --------------------------- { FontAtlas::DirtyRect r; r.Add(3, 3, 0, 5); r.Add(3, 3, 5, 0); r.Add(3, 3, -1, -1); Check(r.Empty(), "zero/negative-extent Adds leave the rect empty"); r.Add(3, 3, 5, 5); r.Add(50, 50, 0, 0); // must not move the max corner out Check(BoxIs(r, 1024, 3, 3, 5, 5), "degenerate Add after a real one is ignored"); } // --- DirtyRect: clamp to atlas extent ---------------------------------- { FontAtlas::DirtyRect r; r.Add(-4, -4, 8, 8); // straddles the top-left corner Check(BoxIs(r, 1024, 0, 0, 4, 4), "negative origin clamps to 0, extent trimmed"); FontAtlas::DirtyRect r2; r2.Add(1020, 1020, 16, 16); // straddles the bottom-right corner Check(BoxIs(r2, 1024, 1020, 1020, 4, 4), "over-extent box clamps to the atlas edge"); } // --- FontAtlas::MarkDirty keeps `dirty` and the rect in sync ----------- { FontAtlas atlas; Check(!atlas.dirty && atlas.dirtyRect.Empty(), "default FontAtlas is clean"); atlas.MarkDirty(0, 0, FontAtlas::kAtlasSize, FontAtlas::kAtlasSize); Check(atlas.dirty, "MarkDirty sets the public dirty flag"); Check(BoxIs(atlas.dirtyRect, FontAtlas::kAtlasSize, 0, 0, FontAtlas::kAtlasSize, FontAtlas::kAtlasSize), "full-atlas MarkDirty covers the whole extent (the Initialize clear)"); atlas.MarkDirty(8, 8, 16, 16); // contained — full extent already dirty Check(BoxIs(atlas.dirtyRect, FontAtlas::kAtlasSize, 0, 0, FontAtlas::kAtlasSize, FontAtlas::kAtlasSize), "subsequent contained MarkDirty keeps the full extent"); // Simulate the reset Update performs after a successful upload. atlas.dirty = false; atlas.dirtyRect.Reset(); Check(!atlas.dirty && atlas.dirtyRect.Empty(), "post-upload reset clears dirtiness"); // After reset, a single glyph marks only its own rect. atlas.MarkDirty(64, 128, 12, 20); Check(atlas.dirty, "a glyph after reset re-arms dirty"); Check(BoxIs(atlas.dirtyRect, FontAtlas::kAtlasSize, 64, 128, 12, 20), "post-reset MarkDirty tracks just the new glyph, not the old full extent"); } if (failures != 0) { std::println("{} check(s) failed", failures); return EXIT_FAILURE; } std::println("all checks passed"); return EXIT_SUCCESS; }