diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issues15151.xaml b/src/Controls/tests/TestCases.HostApp/Issues/Issues15151.xaml new file mode 100644 index 000000000000..f4641e9cc8c6 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issues15151.xaml @@ -0,0 +1,28 @@ + + + + + + + \ No newline at end of file diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issues15151.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issues15151.xaml.cs new file mode 100644 index 000000000000..0e8657448b9d --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issues15151.xaml.cs @@ -0,0 +1,158 @@ +using System; +using Microsoft.Maui.Graphics; + +namespace Maui.Controls.Sample.Issues +{ + [Issue(IssueTracker.Github, 15151, "PathF.Bounds returns too big boxes", PlatformAffected.All)] + public partial class Issues15151 : ContentPage + { + public Issues15151() + { + InitializeComponent(); + DrawRectangleExample(); + DrawBezierExample(); + DrawOvalExample(); + } + + private void DrawRectangleExample() + { + // Create a simple rectangle path + var path = new PathF(); + path.AppendRectangle(50, 50, 150, 100); + + var tightBounds = path.CalculateTightBounds(); + var flattenedBounds = path.GetBoundsByFlattening(); + + RectangleTightBounds.Text = $"Tight bounds: {tightBounds}"; + RectangleFlattenedBounds.Text = $"Flattened bounds: {flattenedBounds}"; + + RectangleView.Drawable = new PathDrawable(path, tightBounds, flattenedBounds); + } + + private void DrawBezierExample() + { + // Create a bezier curve with control points far outside + var path = new PathF(); + path.MoveTo(50, 100); + path.CurveTo(50, 300, 300, 300, 300, 100); + + var tightBounds = path.CalculateTightBounds(); + var flattenedBounds = path.GetBoundsByFlattening(); + + BezierTightBounds.Text = $"Tight bounds: {tightBounds}"; + BezierFlattenedBounds.Text = $"Flattened bounds: {flattenedBounds}"; + + BezierView.Drawable = new PathDrawable(path, tightBounds, flattenedBounds, true); + } + + private void DrawOvalExample() + { + // Create an oval using the method from the issue + float n = (float)(4 * (Math.Sqrt(2) - 1) / 3); + var path = GetOval(150, 125, 100, 75, n * 100, n * 75, 1); + + var tightBounds = path.CalculateTightBounds(); + var flattenedBounds = path.GetBoundsByFlattening(); + + OvalTightBounds.Text = $"Tight bounds: {tightBounds}"; + OvalFlattenedBounds.Text = $"Flattened bounds: {flattenedBounds}"; + + OvalView.Drawable = new PathDrawable(path, tightBounds, flattenedBounds, true); + } + + private PathF GetOval(float x, float y, float radiusX, float radiusY, float cDx, float cDy, float deviation) + { + PathF path = new PathF(); + + float x1 = 0; + float xm = radiusX; + float x2 = radiusX * 2; + + float y1 = 0; + float ym = radiusY; + float y2 = radiusY * 2; + + x -= radiusX; + y -= radiusY; + + float cX1 = xm - cDx; + float cX2 = xm + cDx; + + float cY1 = ym - cDy; + float cY2 = ym + cDy; + + path.MoveTo(x + xm, y + y2); + path.CurveTo(x + cX1 + deviation * 2, y + y2, x + x1, y + cY2, x + x1, y + ym); + path.CurveTo(x + x1, y + cY1, x + cX1 - deviation, y + y1, x + xm, y + y1); + path.CurveTo(x + cX2, y + y1, x + x2, y + cY1, x + x2, y + ym); + path.CurveTo(x + x2, y + cY2, x + cX2, y + y2, x + xm, y + y2); + path.Close(); + + return path; + } + } + + // Class to draw paths and their bounds + public class PathDrawable : IDrawable + { + private readonly PathF _path; + private readonly RectF _tightBounds; + private readonly RectF _flattenedBounds; + private readonly bool _showControlPoints; + + public PathDrawable(PathF path, RectF tightBounds, RectF flattenedBounds, bool showControlPoints = false) + { + _path = path; + _tightBounds = tightBounds; + _flattenedBounds = flattenedBounds; + _showControlPoints = showControlPoints; + } + + public void Draw(ICanvas canvas, RectF dirtyRect) + { + // Draw the flattened bounds + canvas.StrokeColor = Colors.Red; + canvas.StrokeSize = 1; + canvas.DrawRectangle(_flattenedBounds); + + // Draw the tight bounds + canvas.StrokeColor = Colors.Blue; + canvas.StrokeSize = 1; + canvas.DrawRectangle(_tightBounds); + + // Draw the path + canvas.StrokeColor = Colors.Black; + canvas.StrokeSize = 2; + canvas.DrawPath(_path); + + // Draw control points if requested (for bezier curves) + if (_showControlPoints) + { + canvas.StrokeColor = Colors.Red; + canvas.FillColor = Colors.Red; + + // For simplicity, we're just showing some potential control points + // In a real implementation, we'd extract the actual control points from the path + if (_path.OperationCount > 0) + { + foreach (var point in _path.Points) + { + canvas.FillCircle(point, 3); + } + } + } + + // Add a legend + canvas.FontSize = 10; + canvas.StrokeSize = 1; + + canvas.StrokeColor = Colors.Blue; + canvas.DrawLine(10, 10, 30, 10); + canvas.DrawString("Tight Bounds", 35, 13, HorizontalAlignment.Left); + + canvas.StrokeColor = Colors.Red; + canvas.DrawLine(10, 25, 30, 25); + canvas.DrawString("Flattened Bounds", 35, 28, HorizontalAlignment.Left); + } + } +} \ No newline at end of file diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue15151.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue15151.cs new file mode 100644 index 000000000000..0742a1973461 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue15151.cs @@ -0,0 +1,81 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues +{ + public class Issue15151 : _IssuesUITest + { + public override string Issue => "15151"; + + public Issue15151(TestDevice device) : base(device) + { + } + + [Test] + [Category(UITestCategories.Graphics)] + public void PathFBoundsShouldReturnAccurateBounds() + { + // Wait for the test page to load and elements to be displayed + var rectangleTightBounds = App.WaitForElement("RectangleTightBounds"); + var rectangleFlattenedBounds = App.WaitForElement("RectangleFlattenedBounds"); + var bezierTightBounds = App.WaitForElement("BezierTightBounds"); + var bezierFlattenedBounds = App.WaitForElement("BezierFlattenedBounds"); + var ovalTightBounds = App.WaitForElement("OvalTightBounds"); + var ovalFlattenedBounds = App.WaitForElement("OvalFlattenedBounds"); + + // Verify the bounds elements exist and have text + Assert.IsNotEmpty(rectangleTightBounds); + Assert.IsNotEmpty(rectangleFlattenedBounds); + Assert.IsNotEmpty(bezierTightBounds); + Assert.IsNotEmpty(bezierFlattenedBounds); + Assert.IsNotEmpty(ovalTightBounds); + Assert.IsNotEmpty(ovalFlattenedBounds); + + // Get the text from the bounds labels + var rectTightText = App.FindElement("RectangleTightBounds").GetText(); + var rectFlatText = App.FindElement("RectangleFlattenedBounds").GetText(); + var bezierTightText = App.FindElement("BezierTightBounds").GetText(); + var bezierFlatText = App.FindElement("BezierFlattenedBounds").GetText(); + var ovalTightText = App.FindElement("OvalTightBounds").GetText(); + var ovalFlatText = App.FindElement("OvalFlattenedBounds").GetText(); + + // Save a screenshot for verification + VerifyScreenshot("PathFBoundsTightVsFlattened"); + + // Basic value verifications + // 1. For rectangle, tight and flattened bounds should be the same or very close + Assert.AreEqual(rectTightText, rectFlatText); + + // 2. For bezier curve, tight bounds should be smaller than flattened bounds + // Extract the height values for comparison + var bezierTightHeight = ExtractHeightFromBounds(bezierTightText); + var bezierFlatHeight = ExtractHeightFromBounds(bezierFlatText); + Assert.Less(bezierTightHeight, bezierFlatHeight, "Bezier tight bounds height should be less than flattened bounds height"); + + // 3. For oval, tight bounds should also be smaller than flattened bounds + var ovalTightHeight = ExtractHeightFromBounds(ovalTightText); + var ovalFlatHeight = ExtractHeightFromBounds(ovalFlatText); + Assert.Less(ovalTightHeight, ovalFlatHeight, "Oval tight bounds height should be less than flattened bounds height"); + } + + // Helper method to extract the height value from a bounds text string + private float ExtractHeightFromBounds(string boundsText) + { + // Example input: "Tight bounds: {X=50 Y=50 Width=150 Height=100}" + // Extract the value after "Height=" and before "}" + var heightStart = boundsText.IndexOf("Height=") + 7; + var heightEnd = boundsText.IndexOf("}", heightStart); + var heightString = boundsText.Substring(heightStart, heightEnd - heightStart); + + // Handle potential spaces or commas + heightString = heightString.Trim(); + if (heightString.Contains(" ")) + { + heightString = heightString.Substring(0, heightString.IndexOf(" ")); + } + + return float.Parse(heightString); + } + } +} \ No newline at end of file diff --git a/src/Graphics/samples/GraphicsTester.Portable/Scenarios/PathBoundsTester.cs b/src/Graphics/samples/GraphicsTester.Portable/Scenarios/PathBoundsTester.cs new file mode 100644 index 000000000000..0c35441de02c --- /dev/null +++ b/src/Graphics/samples/GraphicsTester.Portable/Scenarios/PathBoundsTester.cs @@ -0,0 +1,85 @@ +using Microsoft.Maui.Graphics; +using System; + +namespace GraphicsTester.Scenarios +{ + public class PathBoundsTester : AbstractScenario + { + public PathBoundsTester() : base(720, 1024) + { + } + + public override void Draw(ICanvas canvas) + { + TestSimpleRectangle(canvas); + TestCubicBezierCurve(canvas); + TestComplexPath(canvas); + } + + private void TestSimpleRectangle(ICanvas canvas) + { + var path = new PathF(); + path.MoveTo(50, 50); + path.LineTo(250, 50); + path.LineTo(250, 250); + path.LineTo(50, 250); + path.Close(); + + var tightBounds = path.CalculateTightBounds(); + var flattenedBounds = path.GetBoundsByFlattening(); + + canvas.StrokeColor = Colors.Black; + canvas.DrawPath(path); + + canvas.FontSize = 12; + canvas.DrawString($"Simple Rectangle", 50, 280); + canvas.DrawString($"Tight bounds: {tightBounds}", 50, 300); + canvas.DrawString($"Flattened bounds: {flattenedBounds}", 50, 320); + } + + private void TestCubicBezierCurve(ICanvas canvas) + { + var path = new PathF(); + path.MoveTo(50, 400); + path.CurveTo(50, 900, 494, 900, 494, 400); + + var tightBounds = path.CalculateTightBounds(); + var flattenedBounds = path.GetBoundsByFlattening(); + + canvas.StrokeColor = Colors.Black; + canvas.DrawPath(path); + + // Draw control points to show how they're outside the actual curve + canvas.StrokeColor = Colors.Red; + canvas.DrawCircle(50, 900, 5); + canvas.DrawCircle(494, 900, 5); + + canvas.FontSize = 12; + canvas.DrawString($"Cubic Bezier Curve with Far Control Points", 50, 600); + canvas.DrawString($"Tight bounds: {tightBounds}", 50, 620); + canvas.DrawString($"Flattened bounds: {flattenedBounds}", 50, 640); + canvas.DrawString($"Red dots show control points outside curve", 50, 660); + } + + private void TestComplexPath(ICanvas canvas) + { + var path = new PathF(); + path.MoveTo(50, 700); + path.LineTo(150, 700); + path.QuadTo(200, 750, 150, 800); + path.CurveTo(100, 850, 0, 850, 50, 800); + path.Close(); + + var tightBounds = path.CalculateTightBounds(); + var flattenedBounds = path.GetBoundsByFlattening(); + + canvas.StrokeColor = Colors.Black; + canvas.DrawPath(path); + + canvas.FontSize = 12; + canvas.DrawString($"Complex Path with Multiple Segment Types", 50, 900); + canvas.DrawString($"Tight bounds: {tightBounds}", 50, 920); + canvas.DrawString($"Flattened bounds: {flattenedBounds}", 50, 940); + } + } +} \ No newline at end of file diff --git a/src/Graphics/samples/GraphicsTester.Portable/Scenarios/ScenarioList.cs b/src/Graphics/samples/GraphicsTester.Portable/Scenarios/ScenarioList.cs index 983f7da39e54..dcd0aa043198 100644 --- a/src/Graphics/samples/GraphicsTester.Portable/Scenarios/ScenarioList.cs +++ b/src/Graphics/samples/GraphicsTester.Portable/Scenarios/ScenarioList.cs @@ -62,6 +62,7 @@ public static List Scenarios new SubtractFromClip(), new DimensionTest(), new ScaleCanvas(), + new PathBoundsTester(), }; } diff --git a/src/Graphics/src/Graphics/PathF.cs b/src/Graphics/src/Graphics/PathF.cs index 748ed21b1a5b..dacb3285fc6f 100644 --- a/src/Graphics/src/Graphics/PathF.cs +++ b/src/Graphics/src/Graphics/PathF.cs @@ -1408,7 +1408,7 @@ public RectF Bounds _cachedBounds = Platform.GraphicsExtensions.AsRectangleF(cgPath.PathBoundingBox); #else - _cachedBounds = GetBoundsByFlattening(); + _cachedBounds = CalculateTightBounds(); #endif return (RectF)_cachedBounds; @@ -1452,6 +1452,247 @@ public RectF GetBoundsByFlattening(float flatness = 0.001f) _cachedBounds = new RectF(l, t, r - l, b - t); return (RectF)_cachedBounds; } + private RectF CalculateTightBounds() + { + if (_points == null || _points.Count == 0) + { + return new RectF(0, 0, 0, 0); + } + + float minX = float.MaxValue; + float minY = float.MaxValue; + float maxX = float.MinValue; + float maxY = float.MinValue; + + int pointIndex = 0; + int arcAngleIndex = 0; + int arcClockwiseIndex = 0; + + // Process each operation in the path + for (int i = 0; i < _operations.Count; i++) + { + PathOperation operation = _operations[i]; + switch (operation) + { + case PathOperation.Move: + case PathOperation.Line: + { + // For move and line operations, simply include the point + PointF point = _points[pointIndex++]; + minX = Math.Min(minX, point.X); + minY = Math.Min(minY, point.Y); + maxX = Math.Max(maxX, point.X); + maxY = Math.Max(maxY, point.Y); + break; + } + + case PathOperation.Quad: + { + // For quadratic bezier curves, we need the extreme points + PointF startPoint = (i > 0 && _operations[i - 1] != PathOperation.Close) ? _points[pointIndex - 1] : _points[pointIndex - 1]; // Use previous point as start + PointF controlPoint = _points[pointIndex++]; + PointF endPoint = _points[pointIndex++]; + + // Include start and end points in bounds + minX = Math.Min(minX, Math.Min(startPoint.X, endPoint.X)); + minY = Math.Min(minY, Math.Min(startPoint.Y, endPoint.Y)); + maxX = Math.Max(maxX, Math.Max(startPoint.X, endPoint.X)); + maxY = Math.Max(maxY, Math.Max(startPoint.Y, endPoint.Y)); + + // Find extreme points for quadratic bezier curve + FindQuadBezierExtremePoints(startPoint, controlPoint, endPoint, ref minX, ref minY, ref maxX, ref maxY); + break; + } + + case PathOperation.Cubic: + { + // For cubic bezier curves, we need the extreme points + PointF startPoint = (i > 0 && _operations[i - 1] != PathOperation.Close) ? _points[pointIndex - 1] : _points[pointIndex - 1]; // Use previous point as start + PointF controlPoint1 = _points[pointIndex++]; + PointF controlPoint2 = _points[pointIndex++]; + PointF endPoint = _points[pointIndex++]; + + // Include start and end points in bounds + minX = Math.Min(minX, Math.Min(startPoint.X, endPoint.X)); + minY = Math.Min(minY, Math.Min(startPoint.Y, endPoint.Y)); + maxX = Math.Max(maxX, Math.Max(startPoint.X, endPoint.X)); + maxY = Math.Max(maxY, Math.Max(startPoint.Y, endPoint.Y)); + + // Find extreme points for cubic bezier curve + FindCubicBezierExtremePoints(startPoint, controlPoint1, controlPoint2, endPoint, ref minX, ref minY, ref maxX, ref maxY); + break; + } + + case PathOperation.Arc: + { + // For arc operations, we need to find the bounds + PointF topLeft = _points[pointIndex++]; + PointF bottomRight = _points[pointIndex++]; + float startAngle = _arcAngles[arcAngleIndex++]; + float endAngle = _arcAngles[arcAngleIndex++]; + bool clockwise = _arcClockwise[arcClockwiseIndex++]; + + // For an arc, the points are the rectangle that bounds the ellipse + // Just ensure these points are included + minX = Math.Min(minX, Math.Min(topLeft.X, bottomRight.X)); + minY = Math.Min(minY, Math.Min(topLeft.Y, bottomRight.Y)); + maxX = Math.Max(maxX, Math.Max(topLeft.X, bottomRight.X)); + maxY = Math.Max(maxY, Math.Max(topLeft.Y, bottomRight.Y)); + break; + } + + case PathOperation.Close: + // Close doesn't affect bounds + break; + + default: + throw new ArgumentOutOfRangeException(nameof(operation), operation, $"Unexpected path operation: {operation}"); + } + } + + // If no bounds were found, return empty rect + if (minX == float.MaxValue) + { + return new RectF(0, 0, 0, 0); + } + + return new RectF(minX, minY, maxX - minX, maxY - minY); + } + + private void FindQuadBezierExtremePoints(PointF p0, PointF p1, PointF p2, ref float minX, ref float minY, ref float maxX, ref float maxY) + { + // For a quadratic bezier curve, extreme points can occur when t = 0, t = 1, or when the derivative is zero + // p(t) = (1-t)²p0 + 2(1-t)tp1 + t²p2 + // p'(t) = 2(1-t)(p1-p0) + 2t(p2-p1) + // p'(t) = 0 when t = (p1-p0)/((p1-p0)+(p2-p1)) + + // Check X-coordinate + if (p1.X != p0.X && p1.X != p2.X) + { + float tx = (p1.X - p0.X) / (2 * p1.X - p0.X - p2.X); + if (tx > 0 && tx < 1) + { + float x = (1 - tx) * (1 - tx) * p0.X + 2 * (1 - tx) * tx * p1.X + tx * tx * p2.X; + minX = Math.Min(minX, x); + maxX = Math.Max(maxX, x); + } + } + + // Check Y-coordinate + if (p1.Y != p0.Y && p1.Y != p2.Y) + { + float ty = (p1.Y - p0.Y) / (2 * p1.Y - p0.Y - p2.Y); + if (ty > 0 && ty < 1) + { + float y = (1 - ty) * (1 - ty) * p0.Y + 2 * (1 - ty) * ty * p1.Y + ty * ty * p2.Y; + minY = Math.Min(minY, y); + maxY = Math.Max(maxY, y); + } + } + } + + private void FindCubicBezierExtremePoints(PointF p0, PointF p1, PointF p2, PointF p3, ref float minX, ref float minY, ref float maxX, ref float maxY) + { + // For a cubic bezier curve, extreme points can occur when t = 0, t = 1, or when the derivative is zero + // We need to solve a quadratic equation for each dimension + + // For X-coordinate: Find roots of derivative equation + float a = 3 * (-p0.X + 3 * p1.X - 3 * p2.X + p3.X); + float b = 6 * (p0.X - 2 * p1.X + p2.X); + float c = 3 * (p1.X - p0.X); + + if (Math.Abs(a) > 0.0001f) // Not a degenerate case + { + // Quadratic formula: (-b ± sqrt(b² - 4ac))/(2a) + float discriminant = b * b - 4 * a * c; + if (discriminant >= 0) + { + float sqrtDisc = (float)Math.Sqrt(discriminant); + float t1 = (-b + sqrtDisc) / (2 * a); + float t2 = (-b - sqrtDisc) / (2 * a); + + // Check if t1 is in [0,1] + if (t1 >= 0 && t1 <= 1) + { + float x = EvaluateCubicBezier(t1, p0.X, p1.X, p2.X, p3.X); + minX = Math.Min(minX, x); + maxX = Math.Max(maxX, x); + } + + // Check if t2 is in [0,1] + if (t2 >= 0 && t2 <= 1) + { + float x = EvaluateCubicBezier(t2, p0.X, p1.X, p2.X, p3.X); + minX = Math.Min(minX, x); + maxX = Math.Max(maxX, x); + } + } + } + else if (Math.Abs(b) > 0.0001f) // Linear case + { + float t = -c / b; + if (t >= 0 && t <= 1) + { + float x = EvaluateCubicBezier(t, p0.X, p1.X, p2.X, p3.X); + minX = Math.Min(minX, x); + maxX = Math.Max(maxX, x); + } + } + + // For Y-coordinate: Find roots of derivative equation + a = 3 * (-p0.Y + 3 * p1.Y - 3 * p2.Y + p3.Y); + b = 6 * (p0.Y - 2 * p1.Y + p2.Y); + c = 3 * (p1.Y - p0.Y); + + if (Math.Abs(a) > 0.0001f) // Not a degenerate case + { + // Quadratic formula: (-b ± sqrt(b² - 4ac))/(2a) + float discriminant = b * b - 4 * a * c; + if (discriminant >= 0) + { + float sqrtDisc = (float)Math.Sqrt(discriminant); + float t1 = (-b + sqrtDisc) / (2 * a); + float t2 = (-b - sqrtDisc) / (2 * a); + + // Check if t1 is in [0,1] + if (t1 >= 0 && t1 <= 1) + { + float y = EvaluateCubicBezier(t1, p0.Y, p1.Y, p2.Y, p3.Y); + minY = Math.Min(minY, y); + maxY = Math.Max(maxY, y); + } + + // Check if t2 is in [0,1] + if (t2 >= 0 && t2 <= 1) + { + float y = EvaluateCubicBezier(t2, p0.Y, p1.Y, p2.Y, p3.Y); + minY = Math.Min(minY, y); + maxY = Math.Max(maxY, y); + } + } + } + else if (Math.Abs(b) > 0.0001f) // Linear case + { + float t = -c / b; + if (t >= 0 && t <= 1) + { + float y = EvaluateCubicBezier(t, p0.Y, p1.Y, p2.Y, p3.Y); + minY = Math.Min(minY, y); + maxY = Math.Max(maxY, y); + } + } + } + + private float EvaluateCubicBezier(float t, float p0, float p1, float p2, float p3) + { + float oneMinusT = 1 - t; + return oneMinusT * oneMinusT * oneMinusT * p0 + + 3 * oneMinusT * oneMinusT * t * p1 + + 3 * oneMinusT * t * t * p2 + + t * t * t * p3; + } + + public PathF GetFlattenedPath(float flatness = .001f, bool includeSubPaths = false) { diff --git a/src/Graphics/tests/Graphics.Tests/PathBoundsTests.cs b/src/Graphics/tests/Graphics.Tests/PathBoundsTests.cs new file mode 100644 index 000000000000..31e47d5dc64a --- /dev/null +++ b/src/Graphics/tests/Graphics.Tests/PathBoundsTests.cs @@ -0,0 +1,237 @@ +using System; +using Microsoft.Maui.Graphics; +using Xunit; + +namespace Graphics.Tests +{ + public class PathBoundsTests + { + private const float FloatComparisonDelta = 0.001f; + + [Fact] + public void EmptyPath_ShouldReturnZeroBounds() + { + // Arrange + var path = new PathF(); + + // Act + var bounds = path.CalculateTightBounds(); + + // Assert + Assert.Equal(0, bounds.X); + Assert.Equal(0, bounds.Y); + Assert.Equal(0, bounds.Width); + Assert.Equal(0, bounds.Height); + } + + [Fact] + public void SinglePoint_ShouldReturnZeroSizeBounds() + { + // Arrange + var path = new PathF(); + path.MoveTo(10, 20); + + // Act + var bounds = path.CalculateTightBounds(); + + // Assert + Assert.Equal(10, bounds.X); + Assert.Equal(20, bounds.Y); + Assert.Equal(0, bounds.Width); + Assert.Equal(0, bounds.Height); + } + + [Fact] + public void LinePath_ShouldReturnCorrectBounds() + { + // Arrange + var path = new PathF(); + path.MoveTo(10, 20); + path.LineTo(100, 200); + + // Act + var bounds = path.CalculateTightBounds(); + + // Assert + Assert.Equal(10, bounds.X); + Assert.Equal(20, bounds.Y); + Assert.Equal(90, bounds.Width); + Assert.Equal(180, bounds.Height); + } + + [Fact] + public void RectanglePath_ShouldReturnCorrectBounds() + { + // Arrange + var path = new PathF(); + path.AppendRectangle(0, 0, 200, 200); + + // Act + var bounds = path.CalculateTightBounds(); + + // Assert + Assert.Equal(0, bounds.X); + Assert.Equal(0, bounds.Y); + Assert.Equal(200, bounds.Width); + Assert.Equal(200, bounds.Height); + } + + [Fact] + public void QuadBezierPath_ShouldReturnCorrectBounds_WithControlPointInside() + { + // Arrange + var path = new PathF(); + path.MoveTo(0, 0); + path.QuadTo(50, 50, 100, 0); // Control point inside the start and end points' box + + // Act + var bounds = path.CalculateTightBounds(); + + // Assert + Assert.Equal(0, bounds.X); + Assert.Equal(0, bounds.Y); + Assert.Equal(100, bounds.Width); + Assert.Equal(25, bounds.Height, FloatComparisonDelta); // Highest point is at y=25 when t=0.5 + } + + [Fact] + public void QuadBezierPath_ShouldReturnCorrectBounds_WithControlPointOutside() + { + // Arrange + var path = new PathF(); + path.MoveTo(0, 0); + path.QuadTo(50, 100, 100, 0); // Control point well outside the start and end points' box + + // Act + var bounds = path.CalculateTightBounds(); + + // Assert + Assert.Equal(0, bounds.X); + Assert.Equal(0, bounds.Y, FloatComparisonDelta); + Assert.Equal(100, bounds.Width); + Assert.Equal(50, bounds.Height, FloatComparisonDelta); // Highest point is at y=50 when t=0.5 + } + + [Fact] + public void CubicBezierPath_ShouldReturnCorrectBounds_WithControlPointsInside() + { + // Arrange + var path = new PathF(); + path.MoveTo(0, 0); + path.CurveTo(25, 25, 75, 25, 100, 0); // Control points inside + + // Act + var bounds = path.CalculateTightBounds(); + + // Assert + Assert.Equal(0, bounds.X); + Assert.Equal(0, bounds.Y); + Assert.Equal(100, bounds.Width); + // Maximum height occurs somewhere around t=0.5, but exact value depends on control points + Assert.True(bounds.Height > 0 && bounds.Height < 25); + } + + [Fact] + public void CubicBezierPath_ShouldReturnCorrectBounds_WithControlPointsOutside() + { + // Arrange + var path = new PathF(); + path.MoveTo(0, 0); + path.CurveTo(25, 100, 75, 100, 100, 0); // Control points far outside + + // Act + var bounds = path.CalculateTightBounds(); + + // Assert + Assert.Equal(0, bounds.X); + Assert.Equal(0, bounds.Y); + Assert.Equal(100, bounds.Width); + Assert.True(bounds.Height > 50 && bounds.Height < 100); // Highest point depends on control points + } + + [Fact] + public void CubicBezierPath_CompareWithFlattening_ShouldBeMoreAccurate() + { + // Arrange - Create a path with a cubic bezier curve with control points far outside the actual curve + var path = new PathF(); + path.MoveTo(0, 0); + path.CurveTo(0, 500, 444, 500, 444, 0); + + // Act + var tightBounds = path.CalculateTightBounds(); + var flattenedBounds = path.GetBoundsByFlattening(); + + // Assert + // The tight bounds should be within the expected range + Assert.Equal(0, tightBounds.X); + Assert.Equal(0, tightBounds.Y); + Assert.Equal(444, tightBounds.Width); + Assert.True(tightBounds.Height < flattenedBounds.Height); + + // The height of tight bounds should be significantly less than the + // height of flattened bounds which would include control points + Assert.True(tightBounds.Height < 400); + } + + [Fact] + public void ComplexPath_WithMultipleSegments_ShouldReturnCorrectBounds() + { + // Arrange + var path = new PathF(); + path.MoveTo(100, 100); + path.LineTo(200, 100); + path.QuadTo(300, 50, 300, 200); + path.CurveTo(300, 300, 200, 350, 100, 300); + path.Close(); + + // Act + var bounds = path.CalculateTightBounds(); + + // Assert + Assert.True(bounds.X >= 100); + Assert.True(bounds.Y >= 50); + Assert.True(bounds.Width <= 200); + Assert.True(bounds.Height <= 300); + + // Compare with flattened bounds which might be less accurate + var flattenedBounds = path.GetBoundsByFlattening(); + Assert.True(bounds.Width <= flattenedBounds.Width); + Assert.True(bounds.Height <= flattenedBounds.Height); + } + + [Fact] + public void Arc_ShouldReturnCorrectBounds() + { + // Arrange + var path = new PathF(); + path.MoveTo(100, 100); + path.AddArc(50, 50, 150, 150, 0, 90, true); + + // Act + var bounds = path.CalculateTightBounds(); + + // Assert + Assert.True(bounds.X >= 50); + Assert.True(bounds.Y >= 50); + Assert.True(bounds.Width <= 100); + Assert.True(bounds.Height <= 100); + } + + [Fact] + public void CirclePath_ShouldReturnCorrectBounds() + { + // Arrange + var path = new PathF(); + path.AppendCircle(100, 100, 50); + + // Act + var bounds = path.CalculateTightBounds(); + + // Assert + Assert.Equal(50, bounds.X); + Assert.Equal(50, bounds.Y); + Assert.Equal(100, bounds.Width); + Assert.Equal(100, bounds.Height); + } + } +} \ No newline at end of file