diff --git a/PanoramicData.Render.ReferenceGenerator/PanoramicData.Render.ReferenceGenerator.csproj b/PanoramicData.Render.ReferenceGenerator/PanoramicData.Render.ReferenceGenerator.csproj
index 4eb3dc0..5514699 100644
--- a/PanoramicData.Render.ReferenceGenerator/PanoramicData.Render.ReferenceGenerator.csproj
+++ b/PanoramicData.Render.ReferenceGenerator/PanoramicData.Render.ReferenceGenerator.csproj
@@ -8,6 +8,12 @@
false
en
+
+
+
+ <_Parameter1>windows
+
+
diff --git a/PanoramicData.Render.Test/KnuthPlassTests.cs b/PanoramicData.Render.Test/KnuthPlassTests.cs
index 7721e04..5d4bb60 100644
--- a/PanoramicData.Render.Test/KnuthPlassTests.cs
+++ b/PanoramicData.Render.Test/KnuthPlassTests.cs
@@ -427,6 +427,33 @@ public void FindBreaks_LooseFitnessOnMidLine_ProducesMultipleLines()
lines.Should().HaveCountGreaterThan(1);
}
+ [Fact]
+ public async Task FindBreaks_LongWordWrappedParagraph_CompletesQuickly()
+ {
+ // Shaped like RenderCommandEmitter.BuildWrapItems output: space glue stretches 0.5x and shrinks 1/3x.
+ // Before active nodes were deduplicated, a paragraph like this took minutes and millions of nodes.
+ var items = new List();
+ for (var word = 0; word < 40; word++)
+ {
+ if (word > 0)
+ {
+ items.Add(new KnuthPlassGlue(100f, 50f, 100f / 3f));
+ }
+
+ items.Add(new KnuthPlassBox(300f + (word % 7 * 60f)));
+ }
+
+ items.Add(new KnuthPlassGlue(0f, float.PositiveInfinity, 0f));
+ items.Add(ForcedBreak());
+
+ var lines = await Task
+ .Run(() => KnuthPlassAlgorithm.FindBreaks(items, 9000f), TestContext.Current.CancellationToken)
+ .WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+
+ lines.Should().NotBeEmpty();
+ lines[^1].EndIndex.Should().Be(items.Count - 1);
+ }
+
private static KnuthPlassPenalty ForcedBreak() =>
new(0f, KnuthPlassPenalty.NegativeInfinity);
}
diff --git a/PanoramicData.Render/KnuthPlassAlgorithm.cs b/PanoramicData.Render/KnuthPlassAlgorithm.cs
index 9b749a1..27d125e 100644
--- a/PanoramicData.Render/KnuthPlassAlgorithm.cs
+++ b/PanoramicData.Render/KnuthPlassAlgorithm.cs
@@ -195,12 +195,20 @@ public static IReadOnlyList FindBreaks(
activeNodes.RemoveAt(toDeactivate[d]);
}
- // Add new active nodes (keep only the best per fitness class for this position)
+ // Add new active nodes, keeping only the best per (line, fitness class): without this the active set doubles at
+ // every breakpoint while lines are too loose. On a tie the later candidate wins, matching the previous output.
+ var bestByLineAndFitness = new Dictionary<(int Line, int FitnessClass), BreakNode>();
foreach (var node in toActivate)
{
- activeNodes.Add(node);
+ var key = (node.Line, node.FitnessClass);
+ if (!bestByLineAndFitness.TryGetValue(key, out var existing) || node.TotalDemerits <= existing.TotalDemerits)
+ {
+ bestByLineAndFitness[key] = node;
+ }
}
+ activeNodes.AddRange(bestByLineAndFitness.Values);
+
// If a forced break, deactivate all preceding active nodes
if (isForcedBreakHere)
{