diff --git a/OscarWatch.Tests/Performance/PassPolarPlotRenderCacheTests.cs b/OscarWatch.Tests/Performance/PassPolarPlotRenderCacheTests.cs
new file mode 100644
index 0000000..1b841bf
--- /dev/null
+++ b/OscarWatch.Tests/Performance/PassPolarPlotRenderCacheTests.cs
@@ -0,0 +1,97 @@
+// Feature: startup-io-rendering-optimisation, Task 8.2: PassPolarPlotControl render cache unit tests
+
+using Avalonia.Media;
+using FsCheck;
+using FsCheck.Xunit;
+using OscarWatch.Controls;
+
+namespace OscarWatch.Tests.Performance;
+
+///
+/// **Validates: Requirements 8.1, 8.3**
+///
+/// Verifies that returns the same object
+/// reference on repeated calls, and that the returned pen has the expected LineCap/LineJoin properties.
+///
+public class PassPolarPlotRenderCacheTests
+{
+ ///
+ /// GetRoundCapPen returns the same reference for the same colour and thickness on repeated calls.
+ ///
+ [Property(MaxTest = 100)]
+ public bool GetRoundCapPen_returns_same_reference(byte a, byte r, byte g, byte b, PositiveInt thicknessRaw)
+ {
+ var color = Color.FromArgb(a, r, g, b);
+ var thickness = (double)thicknessRaw.Get / 10.0;
+ var cache = new RenderResourceCache();
+
+ var pen1 = cache.GetRoundCapPen(color, thickness);
+ var pen2 = cache.GetRoundCapPen(color, thickness);
+
+ return ReferenceEquals(pen1, pen2);
+ }
+
+ ///
+ /// GetRoundCapPen returns a pen with LineCap = Round and LineJoin = Round.
+ ///
+ [Property(MaxTest = 100)]
+ public bool GetRoundCapPen_has_round_cap_and_join(byte a, byte r, byte g, byte b, PositiveInt thicknessRaw)
+ {
+ var color = Color.FromArgb(a, r, g, b);
+ var thickness = (double)thicknessRaw.Get / 10.0;
+ var cache = new RenderResourceCache();
+
+ var pen = cache.GetRoundCapPen(color, thickness);
+
+ return pen.LineCap == PenLineCap.Round && pen.LineJoin == PenLineJoin.Round;
+ }
+
+ ///
+ /// GetRoundCapPen returns a pen with the correct thickness and brush colour.
+ ///
+ [Property(MaxTest = 100)]
+ public bool GetRoundCapPen_has_correct_thickness_and_color(byte a, byte r, byte g, byte b, PositiveInt thicknessRaw)
+ {
+ var color = Color.FromArgb(a, r, g, b);
+ var thickness = (double)thicknessRaw.Get / 10.0;
+ var cache = new RenderResourceCache();
+
+ var pen = cache.GetRoundCapPen(color, thickness);
+ var brush = pen.Brush as SolidColorBrush;
+
+ if (brush is null)
+ return false;
+
+ return pen.Thickness == thickness && brush.Color == color;
+ }
+
+ ///
+ /// Clear() removes round-cap pen entries so the next call creates a fresh pen.
+ ///
+ [Fact]
+ public void Clear_removes_round_cap_pens()
+ {
+ var cache = new RenderResourceCache();
+ var color = Colors.Red;
+
+ var pen1 = cache.GetRoundCapPen(color, 2.5);
+ cache.Clear();
+ var pen2 = cache.GetRoundCapPen(color, 2.5);
+
+ Assert.NotSame(pen1, pen2);
+ }
+
+ ///
+ /// Different colours produce different pen references (not shared incorrectly).
+ ///
+ [Fact]
+ public void GetRoundCapPen_different_colors_different_pens()
+ {
+ var cache = new RenderResourceCache();
+
+ var pen1 = cache.GetRoundCapPen(Colors.Red, 2.5);
+ var pen2 = cache.GetRoundCapPen(Colors.Blue, 2.5);
+
+ Assert.NotSame(pen1, pen2);
+ }
+}
diff --git a/OscarWatch/Controls/FormattedTextCache.cs b/OscarWatch/Controls/FormattedTextCache.cs
index d4aeb69..b8f4959 100644
--- a/OscarWatch/Controls/FormattedTextCache.cs
+++ b/OscarWatch/Controls/FormattedTextCache.cs
@@ -23,6 +23,15 @@ internal sealed class FormattedTextCache
/// Recreates brushes if the palette colours have changed.
///
public FormattedText Get(string name, double fontSize, UiPalette palette)
+ {
+ return Get(name, fontSize, palette.MapLabelForeground);
+ }
+
+ ///
+ /// Returns a cached FormattedText for the given name, font size, and explicit foreground colour.
+ /// Creates the text on first request; returns the cached instance on subsequent calls with the same key.
+ ///
+ public FormattedText Get(string name, double fontSize, Color foreground)
{
var key = (name, fontSize);
if (!_cache.TryGetValue(key, out var text))
@@ -33,7 +42,7 @@ public FormattedText Get(string name, double fontSize, UiPalette palette)
FlowDirection.LeftToRight,
GetTypeface(),
fontSize,
- GetLabelBrush(palette))
+ new SolidColorBrush(foreground))
{
MaxTextWidth = 120
};
diff --git a/OscarWatch/Controls/PassPolarPlotControl.cs b/OscarWatch/Controls/PassPolarPlotControl.cs
index 0b4c455..396ae22 100644
--- a/OscarWatch/Controls/PassPolarPlotControl.cs
+++ b/OscarWatch/Controls/PassPolarPlotControl.cs
@@ -21,6 +21,7 @@ public class PassPolarPlotControl : ThemeAwareControl
private PassPolarPlotHitTest.HoverPoint? _hoverPoint;
private readonly RenderResourceCache _renderCache = new();
+ private readonly FormattedTextCache _textCache = new();
public static readonly StyledProperty PlotDataProperty =
AvaloniaProperty.Register(nameof(PlotData));
@@ -82,7 +83,7 @@ public override void Render(DrawingContext context)
var local = new Rect(0, 0, w, h);
var (cx, cy, plotRadius) = GetPlotGeometry(w, h);
- context.FillRectangle(new SolidColorBrush(palette.SkyPlotBackground), local);
+ context.FillRectangle(_renderCache.GetBrush(palette.SkyPlotBackground), local);
DrawHorizonDisk(context, cx, cy, plotRadius, palette);
DrawElevationRing(context, cx, cy, plotRadius, 30, palette.SkyPlotRing30, 1);
DrawElevationRing(context, cx, cy, plotRadius, 60, palette.SkyPlotRing60, 1);
@@ -103,11 +104,7 @@ public override void Render(DrawingContext context)
continue;
var color = segment.IsSunlit ? palette.SunlightTimeline : EclipsePathColor;
- var pen = new Pen(new SolidColorBrush(color), 2.5)
- {
- LineCap = PenLineCap.Round,
- LineJoin = PenLineJoin.Round
- };
+ var pen = _renderCache.GetRoundCapPen(color, 2.5);
var first = segment.Points[0];
if (!SkyPlotControl.TryAzElToPoint(cx, cy, plotRadius, first.AzimuthDeg, first.ElevationDeg, out var prev))
@@ -162,16 +159,16 @@ private static (double Cx, double Cy, double PlotRadius) GetPlotGeometry(double
return (cx, cy, plotRadius);
}
- private static void DrawHorizonDisk(DrawingContext context, double cx, double cy, double plotRadius, UiPalette palette)
+ private void DrawHorizonDisk(DrawingContext context, double cx, double cy, double plotRadius, UiPalette palette)
{
var disk = new Rect(cx - plotRadius, cy - plotRadius, plotRadius * 2, plotRadius * 2);
context.DrawEllipse(
- new SolidColorBrush(palette.SkyPlotBackground),
- new Pen(new SolidColorBrush(palette.SkyPlotBorder), 1.5),
+ _renderCache.GetBrush(palette.SkyPlotBackground),
+ _renderCache.GetPen(palette.SkyPlotBorder, 1.5),
disk);
}
- private static void DrawElevationRing(
+ private void DrawElevationRing(
DrawingContext context,
double cx,
double cy,
@@ -182,16 +179,16 @@ private static void DrawElevationRing(
bool dashed = false)
{
var r = (90.0 - Math.Clamp(elevationDeg, 0, 90)) / 90.0 * plotRadius;
- var pen = new Pen(new SolidColorBrush(color), thickness);
- if (dashed)
- pen.DashStyle = DashStyle.Dash;
+ var pen = dashed
+ ? _renderCache.GetDashedPen(color, thickness)
+ : _renderCache.GetPen(color, thickness);
context.DrawEllipse(null, pen, new Rect(cx - r, cy - r, r * 2, r * 2));
}
- private static void DrawAzimuthSpokes(DrawingContext context, double cx, double cy, double plotRadius, UiPalette palette)
+ private void DrawAzimuthSpokes(DrawingContext context, double cx, double cy, double plotRadius, UiPalette palette)
{
- var pen = new Pen(new SolidColorBrush(palette.SkyPlotSpoke), 1);
+ var pen = _renderCache.GetPen(palette.SkyPlotSpoke, 1);
for (var az = 0; az < 360; az += 45)
{
if (!SkyPlotControl.TryAzElToPoint(cx, cy, plotRadius, az, 0, out var spokeEnd))
@@ -201,7 +198,7 @@ private static void DrawAzimuthSpokes(DrawingContext context, double cx, double
}
}
- private static void DrawCardinalLabels(DrawingContext context, double cx, double cy, double plotRadius, UiPalette palette)
+ private void DrawCardinalLabels(DrawingContext context, double cx, double cy, double plotRadius, UiPalette palette)
{
DrawLabel(context, "N", cx, cy - plotRadius - 14, palette);
DrawLabel(context, "S", cx, cy + plotRadius + 4, palette);
@@ -223,21 +220,15 @@ private void DrawHoverMarker(DrawingContext context, double cx, double cy, doubl
HoverMarkerRadiusPx * 2,
HoverMarkerRadiusPx * 2);
context.DrawEllipse(
- new SolidColorBrush(HoverMarkerFill),
- new Pen(new SolidColorBrush(HoverMarkerOutline), 2),
+ _renderCache.GetBrush(HoverMarkerFill),
+ _renderCache.GetPen(HoverMarkerOutline, 2),
rect);
- context.DrawEllipse(null, new Pen(Brushes.White, 1.5), rect);
+ context.DrawEllipse(null, _renderCache.GetPen(Colors.White, 1.5), rect);
}
- private static void DrawLabel(DrawingContext context, string text, double x, double y, UiPalette palette)
+ private void DrawLabel(DrawingContext context, string text, double x, double y, UiPalette palette)
{
- var ft = new FormattedText(
- text,
- System.Globalization.CultureInfo.CurrentCulture,
- FlowDirection.LeftToRight,
- new Typeface(FontFamily.Default, FontStyle.Normal, FontWeight.SemiBold),
- 12,
- new SolidColorBrush(palette.SkyPlotLabel));
+ var ft = _textCache.Get(text, 12, palette.SkyPlotLabel);
context.DrawText(ft, new Point(x, y));
}
diff --git a/OscarWatch/Controls/RenderResourceCache.cs b/OscarWatch/Controls/RenderResourceCache.cs
index 84ed668..76727fe 100644
--- a/OscarWatch/Controls/RenderResourceCache.cs
+++ b/OscarWatch/Controls/RenderResourceCache.cs
@@ -11,6 +11,7 @@ internal sealed class RenderResourceCache
private readonly Dictionary _brushes = new();
private readonly Dictionary<(Color Color, double Thickness), Pen> _pens = new();
private readonly Dictionary<(Color Color, double Thickness), Pen> _dashedPens = new();
+ private readonly Dictionary<(Color Color, double Thickness), Pen> _roundCapPens = new();
///
/// Returns a cached SolidColorBrush for the given colour, creating one on first request.
@@ -56,6 +57,26 @@ public Pen GetDashedPen(Color color, double thickness)
return pen;
}
+ ///
+ /// Returns a cached Pen with LineCap = Round and LineJoin = Round for the given colour and thickness.
+ /// Used for pass path segments that need rounded line endings.
+ ///
+ public Pen GetRoundCapPen(Color color, double thickness)
+ {
+ var key = (color, thickness);
+ if (!_roundCapPens.TryGetValue(key, out var pen))
+ {
+ pen = new Pen(GetBrush(color), thickness)
+ {
+ LineCap = PenLineCap.Round,
+ LineJoin = PenLineJoin.Round
+ };
+ _roundCapPens[key] = pen;
+ }
+
+ return pen;
+ }
+
///
/// Clears all cached brushes and pens. Called when track states composition changes
/// or the colour palette changes (theme switch).
@@ -65,5 +86,6 @@ public void Clear()
_brushes.Clear();
_pens.Clear();
_dashedPens.Clear();
+ _roundCapPens.Clear();
}
}