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) {