fix(desktop): fit the set preview at the largest scale the pane allows

The set preview guessed row widths and settled the scale in four rounds,
which could stick at rows of minimum-width label cells and leave much of
a narrow pane empty. At a given scale, filling each tallest-first row as
far as the pane allows is the best row split, and the packed height only
grows with scale, so the layout now bisects for the largest scale that
fits. A 21-drawing set in a 400x590 pane goes from scale 1.14 with 345 px
used to 2.23 filling the height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-25 19:04:58 -04:00
1 parent 70e8666335
commit 1bb58117af
2 files changed
+68 -36

No files matched your search

@@ -64,6 +64,25 @@ public class DrawingSetPreviewTests
}
}
[Fact]
public void Arrange_FillsANarrowPaneWhenLabelCellsSetTheRowWidth()
{
// A tall, narrow pane where the minimum label cell, not the parts, limits each row.
var sizes = new List<(double, double)>
{
(45, 75), (40, 75), (40, 72), (40, 72), (22, 40), (35, 33), (35, 33), (60, 25),
(60, 25), (75, 22), (75, 22), (22, 22), (35, 30), (35, 30), (20, 20), (25, 15),
(15, 15), (40, 15), (20, 15), (6, 10), (6, 10),
};
var result = DrawingSetPreviewLayout.Arrange(sizes, 400, 590, 17, 90);
// Rows used to be fixed at four label cells, leaving the pane 40% empty at about half this scale.
Assert.True(result.Scale > 2.2, $"scale {result.Scale}");
Assert.True(result.Width * result.Scale <= 400 + 1e-6);
Assert.InRange(result.Height * result.Scale, 585, 590 + 1e-6);
}
[Fact]
public void Arrange_UsesTheViewShape()
{
+49 -36
View File
@@ -6,15 +6,19 @@ namespace OpenNest.Controls
{
/// <summary>
/// Packs a set's drawings into rows for <see cref="DrawingSetPreviewControl"/>, every one at
/// the same scale and in its own orientation. Rows are filled tallest first (next-fit
/// decreasing height) and each row's parts share a baseline with a label strip beneath it.
/// The row width is chosen to give the largest fit scale in the view, and the label strip
/// and minimum cell width are fixed in pixels, so they are solved together with that scale.
/// the same scale and in its own orientation. Rows are filled tallest first and each row's
/// parts share a baseline with a label strip beneath it. The label strip and minimum cell
/// width are fixed in pixels, so the layout is solved for the largest scale that fits the view.
/// </summary>
/// <remarks>
/// At a given scale, filling each row as far as the view's width allows beats any other split
/// of the tallest-first order into rows: each row then starts no earlier, so it is no taller,
/// and there are no more rows. A larger scale only widens cells, so the packed height grows
/// with scale and the largest scale that fits is found by bisection.
/// </remarks>
internal static class DrawingSetPreviewLayout
{
private const int RowWidthCandidates = 48;
private const int ScaleIterations = 4;
private const int ScaleIterations = 60;
/// <summary>Where one drawing lands: its bounding box's lower-left corner and its label cell, in world units (Y up).</summary>
internal readonly record struct Placement(int Index, double PartX, double PartY, double CellX, double CellWidth, double LabelTop);
@@ -50,6 +54,9 @@ namespace OpenNest.Controls
viewWidth = System.Math.Max(viewWidth, 1);
viewHeight = System.Math.Max(viewHeight, 1);
labelPixels = System.Math.Max(labelPixels, 0);
// A cell wider than the view could never be placed, so the floor gives way to the view.
minCellPixels = System.Math.Clamp(minCellPixels, 0, viewWidth);
var clean = sizes.Select(s => (Width: Positive(s.Width), Height: Positive(s.Height))).ToArray();
var largest = clean.Max(s => System.Math.Max(s.Width, s.Height));
var spacing = largest * 0.04;
@@ -58,40 +65,46 @@ namespace OpenNest.Controls
.ThenBy(i => i)
.ToArray();
// Any row narrower than the widest part (plus its spacing) cannot hold it; wider
// than every part side by side is one row. Candidates are spread geometrically between.
var narrowest = clean.Max(s => s.Width);
var widest = clean.Sum(s => s.Width) + spacing * clean.Length;
Result best = null;
for (var c = 0; c <= RowWidthCandidates; c++)
{
var rowWidth = narrowest * System.Math.Pow(widest / narrowest, (double)c / RowWidthCandidates);
var scale = System.Math.Min(viewWidth / widest, viewHeight / clean.Sum(s => s.Height));
Result result = null;
for (var i = 0; i < ScaleIterations; i++)
{
result = Pack(clean, order, rowWidth, spacing, labelPixels / scale, minCellPixels / scale);
scale = System.Math.Min(viewWidth / result.Width, viewHeight / result.Height);
}
Result PackAt(double scale) =>
Pack(clean, order, viewWidth / scale, spacing, labelPixels / scale, minCellPixels / scale);
// The label strip was sized for the previous scale; repack once at the final one
// so the reported scale and the strip agree.
result = Pack(clean, order, rowWidth, spacing, labelPixels / scale, minCellPixels / scale);
result = new Result
{
Placements = result.Placements,
Width = result.Width,
Height = result.Height,
LabelHeight = result.LabelHeight,
Scale = System.Math.Min(viewWidth / result.Width, viewHeight / result.Height),
};
if (best == null || result.Scale > best.Scale)
best = result;
bool Fits(Result packed, double scale) =>
packed.Width * scale <= viewWidth * (1 + 1e-9) && packed.Height * scale <= viewHeight * (1 + 1e-9);
// No scale can exceed the one that fits the widest or the tallest part alone.
var high = System.Math.Min(viewWidth / clean.Max(s => s.Width), viewHeight / clean.Max(s => s.Height));
var low = high * 1e-9;
var packed = PackAt(low);
if (!Fits(packed, low))
{
// Even shrunk to nothing, the label strips of the rows overflow the view: keep
// that packing and shrink it to fit, labels included.
return WithScale(packed, System.Math.Min(viewWidth / packed.Width, viewHeight / packed.Height));
}
return best;
if (Fits(PackAt(high), high))
low = high;
for (var i = 0; i < ScaleIterations && high - low > low * 1e-9; i++)
{
var scale = (low + high) / 2;
if (Fits(PackAt(scale), scale))
low = scale;
else
high = scale;
}
return WithScale(PackAt(low), low);
}
private static Result WithScale(Result packed, double scale) => new()
{
Placements = packed.Placements,
Width = packed.Width,
Height = packed.Height,
LabelHeight = packed.LabelHeight,
Scale = scale,
};
private static Result Pack((double Width, double Height)[] sizes, int[] order, double rowWidth, double spacing,
double labelHeight, double minCell)
{
@@ -101,7 +114,7 @@ namespace OpenNest.Controls
var start = 0;
while (start < order.Length)
{
// Next-fit: take parts while they fit the row; a row always takes at least one.
// Take parts while they fit the row; a row always takes at least one.
var x = 0.0;
var end = start;
while (end < order.Length)