#include "graphics/Batcher.h" #include "graphics/PrimitiveDrawList.h" #include "graphics/PrimitivePath.h" #include "graphics/PrimitiveScene.h" #include "graphics/PrimitiveTessellator.h" #include "graphics/PrimitiveTypes.h" #include "zeroerr/assert.h" #include "zeroerr/unittest.h" #include #include #include #include #include using namespace eve::graphics; TEST_CASE("GraphicsPrimitives.dashPatternOwnsAndValidatesIntervals") { DashPattern dash{{4.f, 2.f, 1.f, 3.f}, 0.5f, DashSpace::WorldUnits}; CHECK(std::fabs(dash.period() - 10.f) < 1e-5f); dash.intervals = {1.f, 2.f, 3.f}; CHECK_THROWS((dash.validate(), false)); dash.intervals = {1.f, 0.f}; CHECK_THROWS((dash.validate(), false)); } TEST_CASE("GraphicsPrimitives.canvas2DRecordsContinuousPolylineDistance") { PrimitiveCanvas2D canvas; PrimitivePaint paint; paint.mode = PaintMode::Stroke; paint.stroke.dash = DashPattern{{3.f, 2.f}, 0.f, DashSpace::ScreenPixels}; std::array points{glm::vec2{0.f, 0.f}, glm::vec2{3.f, 4.f}, glm::vec2{6.f, 4.f}}; canvas.drawPolyline(points, false, paint); REQUIRE_EQ(canvas.commands().size(), 1u); const auto& command = canvas.commands().front(); REQUIRE_EQ(command.cumulativeLengths.size(), 3u); CHECK(std::fabs(command.cumulativeLengths[1] - 5.f) < 1e-5f); CHECK(std::fabs(command.cumulativeLengths[2] - 8.f) < 1e-5f); CHECK_EQ(canvas.statistics().segmentCount, 2u); } TEST_CASE("GraphicsPrimitives.closed3DPolylineIncludesClosingDistance") { SceneDrawContext context; context.viewportSize = {1280, 720}; PrimitiveSceneCanvas3D canvas(context); ScenePrimitivePaint paint; paint.mode = PaintMode::Stroke; std::array points{glm::vec3{0.f, 0.f, 0.f}, glm::vec3{3.f, 0.f, 0.f}, glm::vec3{3.f, 4.f, 0.f}}; canvas.drawPolyline(points, true, paint); const auto& command = canvas.commands().front(); REQUIRE_EQ(command.cumulativeWorldLengths.size(), 4u); CHECK(std::fabs(command.cumulativeWorldLengths.back() - 12.f) < 1e-5f); CHECK_EQ(canvas.statistics().segmentCount, 3u); } TEST_CASE("GraphicsPrimitives.canvasStateSnapshotsTransforms") { SceneDrawContext context; context.viewportSize = {640, 480}; PrimitiveSceneCanvas3D canvas(context); ScenePrimitivePaint paint; canvas.save(); canvas.concat(glm::translate(glm::mat4(1.f), glm::vec3(2.f, 3.f, 4.f))); canvas.drawLine({0.f, 0.f, 0.f}, {1.f, 0.f, 0.f}, paint); canvas.restore(); canvas.drawLine({0.f, 0.f, 0.f}, {1.f, 0.f, 0.f}, paint); REQUIRE_EQ(canvas.commands().size(), 2u); CHECK(std::fabs(canvas.commands()[0].transform[3].x - 2.f) < 1e-5f); CHECK(std::fabs(canvas.commands()[1].transform[3].x) < 1e-5f); } TEST_CASE("GraphicsPrimitives.rejectsInvalidContextAndCapacity") { SceneDrawContext context; context.viewportSize = {0, 720}; CHECK_THROWS((PrimitiveSceneCanvas3D(context), false)); PrimitiveCanvas2D canvas(1); PrimitivePaint paint; canvas.drawLine({0.f, 0.f}, {1.f, 1.f}, paint); CHECK_THROWS((canvas.drawLine({1.f, 1.f}, {2.f, 2.f}, paint), false)); } TEST_CASE("GraphicsPrimitives.explicitRecordingReportsBudgetWithoutPartialMutation") { PrimitivePaint paint; paint.mode = PaintMode::Stroke; PrimitiveCanvas2D canvas(1); const std::array first{glm::vec2{0.f, 0.f}, glm::vec2{1.f, 0.f}}; auto recorded = canvas.tryDrawPolyline(first, false, paint); REQUIRE(recorded.ok()); const std::array second{glm::vec2{0.f, 1.f}, glm::vec2{1.f, 1.f}}; auto rejected = canvas.tryDrawPolyline(second, false, paint); CHECK(!rejected.ok()); REQUIRE(rejected.error() != nullptr); CHECK_EQ(static_cast(rejected.error()->code()), static_cast(eve::DiagnosticCode::Failed)); CHECK_EQ(canvas.commands().size(), 1u); CHECK_EQ(canvas.statistics().droppedCommands, 1u); SceneDrawContext context; context.viewportSize = {640, 480}; PrimitiveSceneCanvas3D sceneCanvas(context, 1); ScenePrimitivePaint scenePaint; scenePaint.mode = PaintMode::Stroke; const std::array line{glm::vec3{0.f, 0.f, -2.f}, glm::vec3{1.f, 0.f, -2.f}}; auto sceneRecorded = sceneCanvas.tryDrawPolyline(line, false, scenePaint); REQUIRE(sceneRecorded.ok()); auto sceneRejected = sceneCanvas.tryDrawPolyline(line, false, scenePaint); CHECK(!sceneRejected.ok()); CHECK_EQ(sceneCanvas.commands().size(), 1u); CHECK_EQ(sceneCanvas.statistics().droppedCommands, 1u); } TEST_CASE("GraphicsPrimitives.pathFlattensBezierAndPreservesClosedContour") { Path2D path; path.moveTo({0.f, 0.f}).quadTo({5.f, 10.f}, {10.f, 0.f}).cubicTo({12.f, -4.f}, {18.f, -4.f}, {20.f, 0.f}).close(); const auto coarse = path.flatten(2.f); const auto fine = path.flatten(0.05f); REQUIRE_EQ(coarse.size(), 1u); CHECK(coarse.front().closed); CHECK(fine.front().points.size() > coarse.front().points.size()); CHECK(std::fabs(fine.front().points.front().x) < 1e-5f); CHECK(std::fabs(fine.front().points.back().x - 20.f) < 1e-5f); } TEST_CASE("GraphicsPrimitives.canvasPathUsesSharedContinuousDistanceModel") { Path2D path; path.moveTo({0.f, 0.f}).lineTo({4.f, 0.f}).lineTo({4.f, 3.f}).close(); PrimitiveCanvas2D canvas; PrimitivePaint paint; paint.mode = PaintMode::Stroke; canvas.drawPath(path, paint); REQUIRE_EQ(canvas.commands().size(), 1u); CHECK(canvas.commands().front().closed); CHECK(std::fabs(canvas.commands().front().cumulativeLengths.back() - 12.f) < 1e-5f); } TEST_CASE("GraphicsPrimitives.concaveClosedPathProducesEarClippedFill") { Path2D path; path.moveTo({0.f, 0.f}).lineTo({8.f, 0.f}).lineTo({8.f, 8.f}).lineTo({4.f, 3.f}).lineTo({0.f, 8.f}).close(); PrimitivePaint paint; paint.mode = PaintMode::FillAndStroke; paint.antialias = false; PrimitiveCanvas2D canvas; canvas.drawPath(path, paint); CHECK_EQ(canvas.triangles().size(), 3u); REQUIRE_EQ(canvas.commands().size(), 1u); CHECK(canvas.commands().front().closed); } TEST_CASE("GraphicsPrimitives.pathFillRulesDistinguishNestedContourWinding") { const auto appendSquare = [](Path2D& path, float minimum, float maximum, bool clockwise) { if (clockwise) path.moveTo({minimum, minimum}) .lineTo({minimum, maximum}) .lineTo({maximum, maximum}) .lineTo({maximum, minimum}) .close(); else path.moveTo({minimum, minimum}) .lineTo({maximum, minimum}) .lineTo({maximum, maximum}) .lineTo({minimum, maximum}) .close(); }; const auto triangleArea = [](const PrimitiveCanvas2D& canvas) { float area = 0.f; for (const TriangleCommand2D& triangle : canvas.triangles()) { const glm::vec2 ab = triangle.points[1] - triangle.points[0]; const glm::vec2 ac = triangle.points[2] - triangle.points[0]; area += std::fabs(ab.x * ac.y - ab.y * ac.x) * 0.5f; } return area; }; PrimitivePaint fill; fill.mode = PaintMode::Fill; fill.antialias = false; Path2D evenOdd; appendSquare(evenOdd, 0.f, 10.f, false); appendSquare(evenOdd, 3.f, 7.f, false); evenOdd.setFillRule(PathFillRule::EvenOdd); PrimitiveCanvas2D evenOddCanvas; evenOddCanvas.drawPath(evenOdd, fill); CHECK(std::fabs(triangleArea(evenOddCanvas) - 84.f) < 1e-4f); Path2D nonZeroSolid; appendSquare(nonZeroSolid, 0.f, 10.f, false); appendSquare(nonZeroSolid, 3.f, 7.f, false); nonZeroSolid.setFillRule(PathFillRule::NonZero); PrimitiveCanvas2D solidCanvas; solidCanvas.drawPath(nonZeroSolid, fill); CHECK(std::fabs(triangleArea(solidCanvas) - 100.f) < 1e-4f); Path2D nonZeroHole; appendSquare(nonZeroHole, 0.f, 10.f, false); appendSquare(nonZeroHole, 3.f, 7.f, true); nonZeroHole.setFillRule(PathFillRule::NonZero); PrimitiveCanvas2D holeCanvas; holeCanvas.drawPath(nonZeroHole, fill); CHECK(std::fabs(triangleArea(holeCanvas) - 84.f) < 1e-4f); } TEST_CASE("GraphicsPrimitives.pathRejectsVerbWithoutContour") { Path2D path; CHECK_THROWS((path.lineTo({1.f, 2.f}), false)); CHECK_THROWS((path.close(), false)); } TEST_CASE("GraphicsPrimitives.spatialWireShapesSharePolylineCommands") { SceneDrawContext context; context.viewportSize = {800, 600}; PrimitiveSceneCanvas3D canvas(context); ScenePrimitivePaint paint; paint.mode = PaintMode::Stroke; paint.stroke.dash = DashPattern{{0.5f, 0.25f}, 0.f, DashSpace::WorldUnits}; canvas.drawAabb({-1.f, -2.f, -3.f}, {1.f, 2.f, 3.f}, paint); CHECK_EQ(canvas.commands().size(), 12u); canvas.drawSphere({0.f, 0.f, 0.f}, 2.f, paint, 8); CHECK_EQ(canvas.commands().size(), 15u); CHECK_EQ(canvas.statistics().segmentCount, 36u); for (const auto& command : canvas.commands()) { CHECK(command.paint.stroke.dash.has_value()); } } TEST_CASE("GraphicsPrimitives.gridCylinderConeAndArrowProduceFiniteCommands") { SceneDrawContext context; context.viewportSize = {800, 600}; PrimitiveSceneCanvas3D canvas(context); ScenePrimitivePaint paint; paint.mode = PaintMode::Stroke; canvas.drawGrid({0.f, 0.f, 0.f}, {4.f, 0.f, 0.f}, {0.f, 0.f, 3.f}, 4, 3, paint); canvas.drawCylinder({0.f, 0.f, 0.f}, {0.f, 2.f, 0.f}, 0.5f, paint, 8); canvas.drawCone({0.f, 2.f, 0.f}, {0.f, -1.f, 0.f}, 1.f, 0.5f, paint, 8); canvas.drawArrow({0.f, 0.f, 0.f}, {2.f, 0.f, 0.f}, 0.5f, 0.2f, paint); CHECK_EQ(canvas.statistics().droppedCommands, 0u); CHECK(canvas.statistics().commandCount > 0u); for (const auto& command : canvas.commands()) { CHECK(std::isfinite(command.cumulativeWorldLengths.back())); } } TEST_CASE("GraphicsPrimitives.resolve2DStrokeProducesPixelWidthQuad") { PrimitiveCanvas2D canvas; PrimitivePaint paint; paint.mode = PaintMode::Stroke; paint.stroke.width = 4.f; paint.antialias = false; canvas.drawLine({10.f, 20.f}, {30.f, 20.f}, paint); const auto triangles = resolvePrimitiveStrokes2D(canvas, {100, 100}); REQUIRE_EQ(triangles.vertices.size(), 6u); CHECK_EQ(triangles.statistics.triangleCount, 2u); const float firstY = triangles.vertices[0].clipPosition.y; const float oppositeY = triangles.vertices[2].clipPosition.y; CHECK(std::fabs(std::fabs(oppositeY - firstY) - 0.08f) < 1e-5f); CHECK_EQ(triangles.statistics.uploadBytes, triangles.vertices.size() * sizeof(PrimitiveTriangleVertex)); } TEST_CASE("GraphicsPrimitives.coverageAaEmitsTransparentOnePixelFringe") { PrimitivePaint paint; paint.mode = PaintMode::Stroke; paint.stroke.width = 4.f; paint.antialias = true; PrimitiveCanvas2D canvas; canvas.drawLine({10.f, 20.f}, {30.f, 20.f}, paint); const auto resolved = resolvePrimitiveStrokes2D(canvas, {100, 100}); CHECK_EQ(resolved.vertices.size(), 18u); CHECK(std::any_of(resolved.vertices.begin(), resolved.vertices.end(), [](const PrimitiveTriangleVertex& vertex) { return vertex.color.a == 0.f; })); CHECK(std::any_of(resolved.vertices.begin(), resolved.vertices.end(), [](const PrimitiveTriangleVertex& vertex) { return vertex.color.a == 1.f; })); Batcher batch; batch.addTriangle({0.f, 0.f}, {1.f, 0.f}, {0.f, 1.f}, Color(1.f), Color(1.f, 1.f, 1.f, 0.5f), Color(1.f, 1.f, 1.f, 0.f)); REQUIRE_EQ(batch.vertices().size(), 3u); CHECK_EQ(batch.vertices()[1].color.a, 0.5f); CHECK_EQ(batch.vertices()[2].color.a, 0.f); } TEST_CASE("GraphicsPrimitives.filledContourCoverageAaUsesVertexAlphaFringe") { PrimitiveCanvas2D canvas; PrimitivePaint fill; fill.mode = PaintMode::Fill; fill.antialias = true; canvas.drawRect({10.f, 10.f}, {30.f, 30.f}, fill); CHECK_EQ(canvas.triangles().size(), 10u); const auto resolved = resolvePrimitiveStrokes2D(canvas, {100, 100}); CHECK(std::any_of(resolved.vertices.begin(), resolved.vertices.end(), [](const PrimitiveTriangleVertex& vertex) { return vertex.color.a == 0.f; })); CHECK(std::any_of(resolved.vertices.begin(), resolved.vertices.end(), [](const PrimitiveTriangleVertex& vertex) { return vertex.color.a == 1.f; })); } TEST_CASE("GraphicsPrimitives.resolve2DPreservesBlendAndSubmissionOrder") { PrimitiveCanvas2D canvas; PrimitivePaint opaque; opaque.mode = PaintMode::Stroke; opaque.blend = BlendMode::Opaque; opaque.antialias = false; canvas.drawLine({0.f, 0.f}, {10.f, 0.f}, opaque); PrimitivePaint additive = opaque; additive.blend = BlendMode::Additive; canvas.drawLine({0.f, 2.f}, {10.f, 2.f}, additive); const auto resolved = resolvePrimitiveStrokes2D(canvas, {100, 100}); REQUIRE_EQ(resolved.batches2D.size(), 2u); CHECK_EQ(static_cast(resolved.batches2D[0].blend), static_cast(BlendMode::Opaque)); CHECK_EQ(static_cast(resolved.batches2D[1].blend), static_cast(BlendMode::Additive)); CHECK_EQ(resolved.batches2D[0].sequence, 0u); CHECK_EQ(resolved.batches2D[1].sequence, 1u); } TEST_CASE("GraphicsPrimitives.resolve2DDashContinuesAcrossPolylineSegments") { PrimitiveCanvas2D canvas; PrimitivePaint paint; paint.mode = PaintMode::Stroke; paint.stroke.width = 2.f; paint.antialias = false; paint.stroke.dash = DashPattern{{6.f, 4.f}, 0.f, DashSpace::ScreenPixels}; const std::array points{glm::vec2{0.f, 10.f}, glm::vec2{5.f, 10.f}, glm::vec2{10.f, 10.f}}; canvas.drawPolyline(points, false, paint); const auto triangles = resolvePrimitiveStrokes2D(canvas, {100, 100}); REQUIRE_EQ(triangles.vertices.size(), 12u); float maximumDistance = 0.f; for (const auto& vertex : triangles.vertices) { maximumDistance = std::max(maximumDistance, vertex.pathDistance); } CHECK(std::fabs(maximumDistance - 6.f) < 1e-5f); } TEST_CASE("GraphicsPrimitives.resolve2DDashPhaseSelectsGapAtStart") { PrimitiveCanvas2D canvas; PrimitivePaint paint; paint.mode = PaintMode::Stroke; paint.stroke.dash = DashPattern{{4.f, 4.f}, 4.f, DashSpace::ScreenPixels}; paint.antialias = false; canvas.drawLine({0.f, 0.f}, {8.f, 0.f}, paint); const auto triangles = resolvePrimitiveStrokes2D(canvas, {100, 100}); REQUIRE_EQ(triangles.vertices.size(), 6u); CHECK(std::fabs(triangles.vertices.front().pathDistance - 4.f) < 1e-5f); CHECK(std::fabs(triangles.vertices[1].pathDistance - 8.f) < 1e-5f); } TEST_CASE("GraphicsPrimitives.rectangleHonorsFillStrokeAndCombinedModes") { PrimitiveCanvas2D canvas; PrimitivePaint paint; paint.mode = PaintMode::Fill; paint.antialias = false; canvas.drawRect({10.f, 20.f}, {30.f, 40.f}, paint); CHECK_EQ(canvas.triangles().size(), 2u); CHECK(canvas.commands().empty()); canvas.reset(); paint.mode = PaintMode::Stroke; canvas.drawRect({10.f, 20.f}, {30.f, 40.f}, paint); CHECK(canvas.triangles().empty()); REQUIRE_EQ(canvas.commands().size(), 1u); CHECK(canvas.commands().front().closed); canvas.reset(); paint.mode = PaintMode::FillAndStroke; canvas.drawRect({10.f, 20.f}, {30.f, 40.f}, paint); CHECK_EQ(canvas.triangles().size(), 2u); CHECK_EQ(canvas.commands().size(), 1u); } TEST_CASE("GraphicsPrimitives.circleEllipseAndArcGenerateRealFillTriangles") { PrimitiveCanvas2D canvas; PrimitivePaint fill; fill.mode = PaintMode::Fill; fill.antialias = false; canvas.drawCircle({50.f, 50.f}, 20.f, fill, 8); CHECK_EQ(canvas.triangles().size(), 8u); CHECK(canvas.commands().empty()); PrimitivePaint combined; combined.mode = PaintMode::FillAndStroke; combined.antialias = false; canvas.drawEllipse({20.f, 20.f}, {10.f, 5.f}, combined, 8); CHECK_EQ(canvas.triangles().size(), 16u); CHECK_EQ(canvas.commands().size(), 1u); canvas.drawArc({20.f, 20.f}, {8.f, 8.f}, 0.f, glm::pi(), combined, 4); CHECK_EQ(canvas.triangles().size(), 20u); CHECK_EQ(canvas.commands().size(), 2u); } TEST_CASE("GraphicsPrimitives.unitCircleGeometryCacheReportsReuse") { PrimitivePaint fill; fill.mode = PaintMode::Fill; PrimitiveCanvas2D canvas; canvas.drawCircle({0.f, 0.f}, 1.f, fill, 37); const std::size_t firstHits = canvas.statistics().cacheHits; canvas.drawEllipse({2.f, 0.f}, {2.f, 1.f}, fill, 37); CHECK_EQ(canvas.statistics().cacheHits, firstHits + 1u); SceneDrawContext context; context.viewportSize = {640, 480}; PrimitiveSceneCanvas3D sceneCanvas(context); ScenePrimitivePaint stroke; stroke.mode = PaintMode::Stroke; sceneCanvas.drawSphere({0.f, 0.f, -4.f}, 1.f, stroke, 41); CHECK(sceneCanvas.statistics().cacheHits >= 2u); } TEST_CASE("GraphicsPrimitives.radialQualityAndAdaptiveLodResolveDeterministically") { CHECK_EQ(resolveRadialSegments({PrimitiveQuality::Low, 0.75f, 0}, 100.f), 16u); CHECK_EQ(resolveRadialSegments({PrimitiveQuality::Medium, 0.75f, 0}, 100.f), 32u); CHECK_EQ(resolveRadialSegments({PrimitiveQuality::High, 0.75f, 0}, 100.f), 64u); CHECK_EQ(resolveRadialSegments({PrimitiveQuality::Adaptive, 0.75f, 23}, 100.f), 23u); SceneDrawContext context; context.viewportSize = {800, 600}; context.projection = glm::perspective(glm::radians(60.f), 4.f / 3.f, 0.1f, 100.f); ScenePrimitivePaint stroke; stroke.mode = PaintMode::Stroke; const RadialTessellation adaptive{PrimitiveQuality::Adaptive, 0.5f, 0}; PrimitiveSceneCanvas3D nearCanvas(context); nearCanvas.drawSphere({0.f, 0.f, -2.f}, 1.f, stroke, adaptive); PrimitiveSceneCanvas3D farCanvas(context); farCanvas.drawSphere({0.f, 0.f, -20.f}, 1.f, stroke, adaptive); REQUIRE(!nearCanvas.commands().empty()); REQUIRE(!farCanvas.commands().empty()); CHECK(nearCanvas.commands().front().points.size() > farCanvas.commands().front().points.size()); } TEST_CASE("GraphicsPrimitives.resolve2DCombinesFillAndStrokeTriangles") { PrimitiveCanvas2D canvas; PrimitivePaint paint; paint.mode = PaintMode::FillAndStroke; paint.stroke.width = 2.f; paint.antialias = false; canvas.drawRect({10.f, 10.f}, {30.f, 30.f}, paint); const auto resolved = resolvePrimitiveStrokes2D(canvas, {100, 100}); CHECK_EQ(resolved.statistics.triangleCount, 14u); CHECK_EQ(resolved.vertices.size(), 42u); } TEST_CASE("GraphicsPrimitives.strokeCapsProduceDistinctEndpointGeometry") { PrimitivePaint paint; paint.mode = PaintMode::Stroke; paint.stroke.width = 4.f; paint.antialias = false; PrimitiveCanvas2D butt; paint.stroke.cap = LineCap::Butt; butt.drawLine({10.f, 20.f}, {30.f, 20.f}, paint); CHECK_EQ(resolvePrimitiveStrokes2D(butt, {100, 100}).vertices.size(), 6u); PrimitiveCanvas2D square; paint.stroke.cap = LineCap::Square; square.drawLine({10.f, 20.f}, {30.f, 20.f}, paint); CHECK_EQ(resolvePrimitiveStrokes2D(square, {100, 100}).vertices.size(), 18u); PrimitiveCanvas2D round; paint.stroke.cap = LineCap::Round; round.drawLine({10.f, 20.f}, {30.f, 20.f}, paint); CHECK_EQ(resolvePrimitiveStrokes2D(round, {100, 100}).vertices.size(), 54u); } TEST_CASE("GraphicsPrimitives.strokeJoinsFillPolylineOuterCorner") { const std::array points{glm::vec2{10.f, 10.f}, glm::vec2{30.f, 10.f}, glm::vec2{30.f, 30.f}}; PrimitivePaint paint; paint.mode = PaintMode::Stroke; paint.stroke.width = 6.f; paint.antialias = false; paint.stroke.join = LineJoin::Bevel; PrimitiveCanvas2D bevel; bevel.drawPolyline(points, false, paint); CHECK_EQ(resolvePrimitiveStrokes2D(bevel, {100, 100}).vertices.size(), 15u); paint.stroke.join = LineJoin::Round; PrimitiveCanvas2D round; round.drawPolyline(points, false, paint); CHECK(resolvePrimitiveStrokes2D(round, {100, 100}).vertices.size() > 15u); } TEST_CASE("GraphicsPrimitives.resolve3DScreenStrokeClipsNearPlane") { SceneDrawContext context; context.viewportSize = {800, 600}; context.nearPlane = 0.1f; context.farPlane = 100.f; context.projection = glm::perspective(glm::radians(60.f), 4.f / 3.f, 0.1f, 100.f); PrimitiveSceneCanvas3D canvas(context); ScenePrimitivePaint paint; paint.mode = PaintMode::Stroke; paint.stroke.width = 3.f; paint.antialias = false; paint.stroke.widthSpace = WidthSpace::ScreenPixels; canvas.drawLine({-1.f, 0.f, -1.f}, {1.f, 0.f, 0.f}, paint); canvas.drawLine({-1.f, 1.f, 1.f}, {1.f, 1.f, 2.f}, paint); const auto triangles = resolvePrimitiveStrokes3D(canvas); REQUIRE_EQ(triangles.vertices.size(), 6u); for (const auto& vertex : triangles.vertices) { CHECK(std::isfinite(vertex.clipPosition.x)); CHECK(std::isfinite(vertex.clipPosition.w)); CHECK(vertex.clipPosition.w > 0.f); } } TEST_CASE("GraphicsPrimitives.resolve3DWorldStrokeExpandsInViewSpace") { SceneDrawContext context; context.viewportSize = {800, 600}; context.projection = glm::perspective(glm::radians(60.f), 4.f / 3.f, 0.1f, 100.f); PrimitiveSceneCanvas3D canvas(context); ScenePrimitivePaint paint; paint.mode = PaintMode::Stroke; paint.stroke.width = 0.2f; paint.antialias = false; paint.stroke.widthSpace = WidthSpace::WorldUnits; canvas.drawLine({-1.f, 0.f, -4.f}, {1.f, 0.f, -4.f}, paint); const auto triangles = resolvePrimitiveStrokes3D(canvas); REQUIRE_EQ(triangles.vertices.size(), 6u); CHECK(std::fabs(triangles.vertices[0].clipPosition.y - triangles.vertices[2].clipPosition.y) > 1e-5f); } TEST_CASE("GraphicsPrimitives.resolve3DStrokeHonorsCapsAndJoins") { SceneDrawContext context; context.viewportSize = {800, 600}; context.projection = glm::perspective(glm::radians(60.f), 4.f / 3.f, 0.1f, 100.f); ScenePrimitivePaint paint; paint.mode = PaintMode::Stroke; paint.stroke.width = 4.f; paint.stroke.widthSpace = WidthSpace::ScreenPixels; paint.antialias = false; PrimitiveSceneCanvas3D square(context); paint.stroke.cap = LineCap::Square; square.drawLine({-1.f, 0.f, -4.f}, {1.f, 0.f, -4.f}, paint); CHECK_EQ(resolvePrimitiveStrokes3D(square).vertices.size(), 18u); PrimitiveSceneCanvas3D round(context); paint.stroke.cap = LineCap::Round; round.drawLine({-1.f, 0.f, -4.f}, {1.f, 0.f, -4.f}, paint); CHECK_EQ(resolvePrimitiveStrokes3D(round).vertices.size(), 54u); PrimitiveSceneCanvas3D bevel(context); paint.stroke.cap = LineCap::Butt; paint.stroke.join = LineJoin::Bevel; const std::array points{glm::vec3{-1.f, 0.f, -4.f}, glm::vec3{0.f, 0.f, -4.f}, glm::vec3{0.f, 1.f, -4.f}}; bevel.drawPolyline(points, false, paint); CHECK_EQ(resolvePrimitiveStrokes3D(bevel).vertices.size(), 15u); } TEST_CASE("GraphicsPrimitives.resolve3DScreenDashUsesProjectedPixelDistance") { SceneDrawContext context; context.viewportSize = {800, 600}; context.projection = glm::perspective(glm::radians(60.f), 4.f / 3.f, 0.1f, 100.f); PrimitiveSceneCanvas3D canvas(context); ScenePrimitivePaint paint; paint.mode = PaintMode::Stroke; paint.stroke.width = 2.f; paint.stroke.dash = DashPattern{{10.f, 10.f}, 0.f, DashSpace::ScreenPixels}; canvas.drawLine({-1.f, 0.f, -4.f}, {1.f, 0.f, -4.f}, paint); const auto triangles = resolvePrimitiveStrokes3D(canvas); CHECK(triangles.vertices.size() > 6u); CHECK_EQ(triangles.vertices.size() % 6u, 0u); } TEST_CASE("GraphicsPrimitives.batcherAcceptsResolvedArbitraryTriangles") { Batcher batch; batch.addTriangle({1.f, 2.f}, {7.f, 3.f}, {2.f, 9.f}, Color(1.f, 0.f, 0.f, 1.f)); REQUIRE_EQ(batch.vertices().size(), 3u); CHECK(std::fabs(batch.vertices()[1].pos.x - 7.f) < 1e-5f); batch.toNDC(10, 10); CHECK(std::fabs(batch.vertices()[0].pos.x - (-0.8f)) < 1e-5f); } TEST_CASE("GraphicsPrimitives.persistentSceneDetectsStaleAndReusedSlots") { PrimitiveScene scene; CHECK(scene.isStale({})); PrimitiveDescriptor3D descriptor; descriptor.geometry = PrimitiveSphere3D{{0.f, 0.f, 0.f}, 1.f, 8}; auto added = scene.add(descriptor); REQUIRE(added.ok()); const PrimitiveHandle first = added.value(); CHECK(scene.tryGet(first) != nullptr); auto removed = scene.remove(first); REQUIRE(removed.ok()); CHECK(scene.isStale(first)); auto addedAgain = scene.add(descriptor); REQUIRE(addedAgain.ok()); const PrimitiveHandle second = addedAgain.value(); CHECK_EQ(second.index(), first.index()); CHECK(second.generation() != first.generation()); auto staleUpdate = scene.update(first, descriptor); CHECK(!staleUpdate.ok()); CHECK_EQ(static_cast(staleUpdate.error()->code()), static_cast(eve::DiagnosticCode::StaleHandle)); } TEST_CASE("GraphicsPrimitives.persistentSceneRejectsInvalidSpatialDescriptorsBeforeRender") { PrimitiveScene scene; const std::array invalidGeometries{{ PrimitiveAabb3D{{1.f, 0.f, 0.f}, {0.f, 1.f, 1.f}}, PrimitiveObb3D{{0.f, 0.f, 0.f}, {{{1.f, 0.f, 0.f}, {2.f, 0.f, 0.f}, {0.f, 0.f, 1.f}}}}, PrimitiveDisk3D{{0.f, 0.f, 0.f}, {0.f, 0.f, 0.f}, 1.f, 8}, PrimitiveArc3D{{0.f, 0.f, 0.f}, {0.f, 1.f, 0.f}, {0.f, 2.f, 0.f}, 1.f, 0.f, 1.f, 8}, PrimitiveCylinder3D{{0.f, 0.f, 0.f}, {0.f, 0.f, 0.f}, 1.f, 8}, PrimitiveCone3D{{0.f, 0.f, 0.f}, {0.f, 0.f, 0.f}, 1.f, 1.f, 8}, PrimitiveGrid3D{{0.f, 0.f, 0.f}, {1.f, 0.f, 0.f}, {2.f, 0.f, 0.f}, 2, 2}, PrimitiveArrow3D{{0.f, 0.f, 0.f}, {0.f, 0.f, 0.f}, 0.2f, 0.1f}, }}; for (const PrimitiveGeometry3D& geometry : invalidGeometries) { PrimitiveDescriptor3D descriptor; descriptor.geometry = geometry; auto added = scene.add(std::move(descriptor)); CHECK(!added.ok()); } CHECK_EQ(scene.size(), 0u); } TEST_CASE("GraphicsPrimitives.sceneRejectsInvalidPaintWithoutMutation") { PrimitiveScene scene; PrimitiveDescriptor3D descriptor; descriptor.geometry = PrimitiveSphere3D{}; auto added = scene.add(descriptor); REQUIRE(added.ok()); const auto handle = added.value(); descriptor.paint.stroke.width = -1.f; auto badWidth = scene.update(handle, descriptor); CHECK(!badWidth.ok()); REQUIRE(scene.tryGet(handle) != nullptr); CHECK_EQ(scene.tryGet(handle)->paint.stroke.width, 1.f); descriptor.paint.stroke.width = 1.f; descriptor.paint.stroke.dash = DashPattern{{1.f, 0.f}, 0.f, DashSpace::WorldUnits}; auto badDash = scene.update(handle, descriptor); CHECK(!badDash.ok()); CHECK(!scene.tryGet(handle)->paint.stroke.dash.has_value()); } TEST_CASE("GraphicsPrimitives.persistentSceneProjectsVisibleDescriptorsInSlotOrder") { PrimitiveScene scene; PrimitiveDescriptor3D hidden; hidden.geometry = PrimitiveAabb3D{glm::vec3(-1.f), glm::vec3(1.f)}; hidden.visible = false; auto hiddenHandle = scene.add(hidden); REQUIRE(hiddenHandle.ok()); PrimitiveDescriptor3D arrow; arrow.geometry = PrimitiveArrow3D{{0.f, 0.f, 0.f}, {0.f, 2.f, 0.f}, 0.4f, 0.2f}; arrow.transform = glm::translate(glm::mat4(1.f), {4.f, 0.f, 0.f}); auto arrowHandle = scene.add(arrow); REQUIRE(arrowHandle.ok()); SceneDrawContext context; context.viewportSize = {800, 600}; PrimitiveSceneCanvas3D canvas(context); scene.render(canvas); CHECK(!canvas.commands().empty()); for (const auto& command : canvas.commands()) { CHECK(std::fabs(command.transform[3].x - 4.f) < 1e-5f); } scene.clear(); CHECK_EQ(scene.size(), 0u); CHECK(scene.isStale(arrowHandle.value())); } TEST_CASE("GraphicsPrimitives.persistentHandlesAreQualifiedByOwningScene") { PrimitiveScene firstScene; PrimitiveScene secondScene; PrimitiveDescriptor3D descriptor; descriptor.geometry = PrimitiveSphere3D{}; auto first = firstScene.add(descriptor); auto second = secondScene.add(descriptor); REQUIRE(first.ok()); REQUIRE(second.ok()); CHECK(first.value().owner() != second.value().owner()); CHECK(secondScene.tryGet(first.value()) == nullptr); CHECK(!secondScene.update(first.value(), descriptor).ok()); } TEST_CASE("GraphicsPrimitives.sceneBatchUpdateIsAtomicOnStaleHandle") { PrimitiveScene scene; PrimitiveDescriptor3D firstDescriptor; firstDescriptor.geometry = PrimitiveSphere3D{{1.f, 0.f, 0.f}, 1.f, 8}; PrimitiveDescriptor3D secondDescriptor; secondDescriptor.geometry = PrimitiveSphere3D{{2.f, 0.f, 0.f}, 1.f, 8}; auto first = scene.add(firstDescriptor); auto second = scene.add(secondDescriptor); REQUIRE(first.ok()); REQUIRE(second.ok()); REQUIRE(scene.remove(second.value()).ok()); PrimitiveDescriptor3D replacement = firstDescriptor; replacement.visible = false; const std::array updates{PrimitiveBatchUpdate{first.value(), replacement}, PrimitiveBatchUpdate{second.value(), replacement}}; auto result = scene.updateMany(updates); CHECK(!result.ok()); REQUIRE(scene.tryGet(first.value()) != nullptr); CHECK(scene.tryGet(first.value())->visible); } TEST_CASE("GraphicsPrimitives.persistentSceneRejectsInvalidGeometryAtomically") { PrimitiveScene scene; PrimitiveDescriptor3D valid; valid.geometry = PrimitiveSphere3D{{0.f, 0.f, 0.f}, 1.f, 8}; auto handle = scene.add(valid); REQUIRE(handle.ok()); PrimitiveDescriptor3D invalid = valid; invalid.geometry = PrimitiveSphere3D{{0.f, 0.f, 0.f}, -1.f, 8}; auto update = scene.update(handle.value(), invalid); CHECK(!update.ok()); REQUIRE(scene.tryGet(handle.value()) != nullptr); const auto* sphere = std::get_if(&scene.tryGet(handle.value())->geometry); REQUIRE(sphere != nullptr); CHECK_EQ(sphere->radius, 1.f); auto add = scene.add(invalid); CHECK(!add.ok()); CHECK_EQ(scene.size(), 1u); }