diff --git a/OpenNest.Engine.Tests/Fill/ExpanderPatternTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderPatternTests.cs new file mode 100644 index 00000000..f8f5bd68 --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/ExpanderPatternTests.cs @@ -0,0 +1,226 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Engine.Fill; +using OpenNest.Engine.Tests.Jobs; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Fill; + +/// +/// Expand Spacing on a selection that repeats on a lattice: the lattice is +/// stretched per axis so the pattern survives. +/// +public class ExpanderPatternTests +{ + private const double Radius = 2; + + private static Program Disk(double radius) + { + var p = new Program(); + p.MoveTo(radius, 0); + p.ArcTo(-radius, 0, 0, 0, RotationType.CCW); + p.ArcTo(radius, 0, 0, 0, RotationType.CCW); + return p; + } + + private static Plate MakePlate(double lengthX, double widthY, double edge) + { + var plate = new Plate(new Size(widthY, lengthX)); + plate.EdgeSpacing = new Spacing(edge, edge); + return plate; + } + + private static Part Add(Plate plate, Drawing drawing, double x, double y) + { + var part = new Part(drawing, new Vector(x, y)); + plate.Parts.Add(part); + return part; + } + + /// + /// Disks staggered like a sheet fill at a 0.2 gap: same-row neighbors and + /// diagonal neighbors both touch at the gap, the rows span the plate's + /// length exactly, and the plate has spare height. + /// + private static (Plate Plate, List Parts) StaggeredDisks() + { + const double gap = 0.2; + const double edge = 0.2; + const int columns = 9; + var pitch = 2 * Radius + gap; + var dx = pitch / 2; + var dy = System.Math.Sqrt(pitch * pitch - dx * dx); + var plate = MakePlate(2 * edge + 2 * Radius + (columns - 1) * dx, 30, edge); + var drawing = new Drawing("disk", Disk(Radius)); + var parts = new List(); + for (var column = 0; column < columns; column++) + { + var odd = column % 2 == 1; + for (var row = 0; row < (odd ? 2 : 3); row++) + parts.Add(Add(plate, drawing, + edge + Radius + column * dx, + edge + Radius + (odd ? dy : 0) + row * 2 * dy)); + } + return (plate, parts); + } + + private static double DiskGap(Part a, Part b) => a.Location.DistanceTo(b.Location) - 2 * Radius; + + [Fact] + public void Expand_StaggeredFillTightAlongX_StretchesOnlyTheRowPitch() + { + var (plate, parts) = StaggeredDisks(); + var xs = parts.Select(p => p.Location.X).ToList(); + var rowMateGap = DiskGap(parts[0], parts[5]); // columns 0 and 2, first row + + var result = Expander.Expand(parts, plate); + + Assert.True(result.KeptPattern); + Assert.True(result.PatternMoved); + Assert.Empty(result.Violations); + for (var i = 0; i < parts.Count; i++) + Assert.Equal(xs[i], parts[i].Location.X, 6); + + // Every column stays evenly pitched and the stagger stays at half a pitch. + var even = parts.Where(p => System.Math.Abs(p.Location.X - xs[0]) < 1e-6) + .Select(p => p.Location.Y).OrderBy(y => y).ToList(); + var odd = parts.Where(p => System.Math.Abs(p.Location.X - xs[3]) < 1e-6) + .Select(p => p.Location.Y).OrderBy(y => y).ToList(); + var rowPitch = even[1] - even[0]; + Assert.Equal(rowPitch, even[2] - even[1], 6); + Assert.Equal(rowPitch, odd[1] - odd[0], 6); + Assert.Equal(even[0] + rowPitch / 2, odd[0], 6); + + // The pattern fills the height and only the diagonal gap opened up. + var work = plate.WorkArea(); + Assert.True(parts.Min(p => p.BoundingBox.Bottom) >= work.Bottom - 1e-9); + Assert.True(parts.Max(p => p.BoundingBox.Top) <= work.Top + 1e-9); + Assert.True(work.Top - parts.Max(p => p.BoundingBox.Top) < 0.02); + Assert.Equal(rowMateGap, DiskGap(parts[0], parts[5]), 6); + Assert.True(DiskGap(parts[0], parts[3]) > 1, $"diagonal gap {DiskGap(parts[0], parts[3])}"); + Assert.Equal(rowMateGap, result.AchievedSpacing, 2); + } + + [Fact] + public void Expand_GridWithRoomBothWays_FillsTheWorkAreaEvenly() + { + var plate = MakePlate(40, 30, 0.5); + var drawing = new Drawing("square", TestDrawingFactory.Rectangle(4, 4)); + var parts = new List(); + for (var row = 0; row < 3; row++) + for (var column = 0; column < 3; column++) + parts.Add(Add(plate, drawing, 1 + column * 5, 1 + row * 5)); + + var result = Expander.Expand(parts, plate); + + Assert.True(result.KeptPattern); + Assert.True(result.PatternMoved); + var xs = parts.Select(p => p.Location.X).Distinct(new Near()).OrderBy(x => x).ToList(); + var ys = parts.Select(p => p.Location.Y).Distinct(new Near()).OrderBy(y => y).ToList(); + Assert.Equal(3, xs.Count); + Assert.Equal(3, ys.Count); + Assert.Equal(xs[1] - xs[0], xs[2] - xs[1], 6); + Assert.Equal(ys[1] - ys[0], ys[2] - ys[1], 6); + Assert.True(xs[1] - xs[0] > 5.5 && ys[1] - ys[0] > 5.5); + + var work = plate.WorkArea(); + Assert.True(parts.Min(p => p.BoundingBox.Left) >= work.Left - 1e-9); + Assert.True(parts.Max(p => p.BoundingBox.Right) <= work.Right + 1e-9); + Assert.True(work.Right - parts.Max(p => p.BoundingBox.Right) + parts.Min(p => p.BoundingBox.Left) - work.Left < 0.02); + Assert.True(work.Top - parts.Max(p => p.BoundingBox.Top) + parts.Min(p => p.BoundingBox.Bottom) - work.Bottom < 0.02); + } + + [Fact] + public void Expand_TwoPartMotif_KeepsEachMotifRigid() + { + var plate = MakePlate(60, 40, 0.5); + var tall = new Drawing("tall", TestDrawingFactory.Rectangle(2, 4)); + var small = new Drawing("small", TestDrawingFactory.Rectangle(1, 1)); + var tallParts = new List(); + var smallParts = new List(); + for (var row = 0; row < 2; row++) + for (var column = 0; column < 3; column++) + { + tallParts.Add(Add(plate, tall, 1 + column * 4, 1 + row * 5)); + smallParts.Add(Add(plate, small, 1 + column * 4 + 2.25, 1 + row * 5)); + } + var offset = smallParts[0].BoundingBox.Center - tallParts[0].BoundingBox.Center; + var selected = tallParts.Concat(smallParts).ToList(); + + var result = Expander.Expand(selected, plate); + + Assert.True(result.KeptPattern); + Assert.True(result.PatternMoved); + for (var i = 0; i < tallParts.Count; i++) + { + var moved = smallParts[i].BoundingBox.Center - tallParts[i].BoundingBox.Center; + Assert.Equal(offset.X, moved.X, 6); + Assert.Equal(offset.Y, moved.Y, 6); + } + Assert.True(tallParts[1].Location.X - tallParts[0].Location.X > 4.5); + } + + [Fact] + public void Expand_PatternBesideFixedPart_StopsAtItsFloor() + { + var plate = MakePlate(40, 12, 0.5); + var drawing = new Drawing("square", TestDrawingFactory.Rectangle(2, 2)); + var parts = new List(); + for (var column = 0; column < 4; column++) + parts.Add(Add(plate, drawing, 1 + column * 3, 1)); + var wall = Add(plate, new Drawing("wall", TestDrawingFactory.Rectangle(2, 2)), 20, 1); + + var result = Expander.Expand(parts, plate); + + Assert.True(result.KeptPattern); + Assert.True(result.PatternMoved); + Assert.Equal(new Vector(20, 1), wall.Location); + // The row's own gap is 1, so the gap to the fixed part closes no further. + Assert.True(wall.BoundingBox.Left - parts.Max(p => p.BoundingBox.Right) >= 1 - 1e-3); + Assert.True(parts[1].Location.X - parts[0].Location.X > 3.5); + for (var i = 1; i < parts.Count; i++) + Assert.Equal(parts[1].Location.X - parts[0].Location.X, parts[i].Location.X - parts[i - 1].Location.X, 6); + } + + [Fact] + public void Expand_IrregularSelection_UsesThePairSearch() + { + var plate = MakePlate(40, 30, 0.5); + var drawing = new Drawing("square", TestDrawingFactory.Rectangle(2, 2)); + var parts = new List + { + Add(plate, drawing, 5, 5), + Add(plate, drawing, 8.3, 5.4), + Add(plate, drawing, 5.7, 9.1), + Add(plate, drawing, 11.6, 12.2), + }; + + var result = Expander.Expand(parts, plate); + + Assert.False(result.KeptPattern); + Assert.Equal(new Vector(5, 5), parts[0].Location); // the pair search's anchor + } + + [Fact] + public void Detect_NeedsMorePartsThanTheLatticeFits() + { + var drawing = new Drawing("square", TestDrawingFactory.Rectangle(1, 1)); + Part At(double x, double y) => new(drawing, new Vector(x, y)); + + Assert.Null(Expander.RepeatingPattern.Detect(new[] { At(0, 0), At(3, 1), At(1, 4) })); + + var row = Expander.RepeatingPattern.Detect(new[] { At(0, 0), At(3, 0), At(6, 0) }); + Assert.NotNull(row); + Assert.Equal(Vector.Zero, row.B); + + Assert.Null(Expander.RepeatingPattern.Detect(new[] { At(0, 0), At(3, 0), At(7, 0) })); + } + + private sealed class Near : IEqualityComparer + { + public bool Equals(double a, double b) => System.Math.Abs(a - b) < 1e-6; + public int GetHashCode(double value) => 0; + } +} diff --git a/OpenNest.Engine/Fill/Expander.cs b/OpenNest.Engine/Fill/Expander.cs index 369d028c..1b42e589 100644 --- a/OpenNest.Engine/Fill/Expander.cs +++ b/OpenNest.Engine/Fill/Expander.cs @@ -24,8 +24,12 @@ namespace OpenNest.Engine.Fill /// selected part moves. Nonselected parts never move; there is no counterpart /// fallback when the prescribed mover is blocked. /// + /// + /// A selection that repeats on a lattice skips the pair search: its lattice + /// is stretched per axis instead, so the pattern is kept (ExpanderPattern.cs). + /// /// - public static class Expander + public static partial class Expander { public sealed class Options { @@ -73,6 +77,13 @@ namespace OpenNest.Engine.Fill /// work-area poses, including unresolved negative clearances when no /// positive target was feasible. public List Violations = new(); + + /// True when the selection repeated on a lattice and was + /// stretched as one pattern rather than pushed apart pair by pair. + public bool KeptPattern; + + /// True when a kept pattern had room to spread and moved. + public bool PatternMoved; } /// Per-call test checkpoints; no process-global mutable hook. @@ -159,6 +170,10 @@ namespace OpenNest.Engine.Fill return result; } + var stretched = StretchPattern(context, entry, selected, sMin, opt, token); + if (stretched != null) + return stretched; + var sGood = sMin; var cap = opt.MaxSpacing > 0 ? opt.MaxSpacing : context.SpacingCap; double hi; // first spacing that failed; double.NaN = none yet @@ -790,6 +805,11 @@ namespace OpenNest.Engine.Fill public bool IsMover(int index) => index < moverCount; + public Box WorkArea => workArea; + + /// True when anything besides the movers can block them. + public bool HasWalls => parts.Count > moverCount; + public Part PartOf(int index) => parts[index]; /// Current world positions of every part in pair-index order. @@ -1023,9 +1043,12 @@ namespace OpenNest.Engine.Fill /// /// Stop at the first material-blocking contact along the whole straight /// translation, including hole walls. Destination clearance alone can - /// accept a pose on the far side of a thin wall. + /// accept a pose on the far side of a thin wall. With + /// , other movers are ignored because they + /// move along with this one. /// - public double ClipToMaterial(int index, List positions, Vector direction, double room) + public double ClipToMaterial( + int index, List positions, Vector direction, double room, bool wallsOnly = false) { if (room <= 0 || !double.IsFinite(room) || !double.IsFinite(direction.X) || !double.IsFinite(direction.Y)) @@ -1036,7 +1059,7 @@ namespace OpenNest.Engine.Fill for (var i = 0; i < parts.Count; i++) { - if (i == index) + if (i == index || wallsOnly && i < moverCount) continue; var stationary = shapes[i]; if (parts[i].BaseDrawing.IsCutOff) diff --git a/OpenNest.Engine/Fill/ExpanderPattern.cs b/OpenNest.Engine/Fill/ExpanderPattern.cs new file mode 100644 index 00000000..ed5f9145 --- /dev/null +++ b/OpenNest.Engine/Fill/ExpanderPattern.cs @@ -0,0 +1,424 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Fill +{ + public static partial class Expander + { + /// + /// The lattice a selection repeats on. Parts of one drawing at one + /// rotation form a motif group; every group sits on the same two + /// translations and , so a fill of + /// interlocked pairs is one pattern with a two-part motif. + /// + internal sealed class RepeatingPattern + { + public Vector A; + + /// Second translation; for a single row. + public Vector B; + + /// + /// Per selected part, the lattice point of its motif copy. Parts of + /// one copy share it, so stretching the lattice keeps every motif rigid. + /// + public Vector[] LatticePoints; + + // Lattice coordinates may miss a whole number by this fraction of a + // pitch, which absorbs the four-decimal rounding of saved positions. + private const double CoordinateTolerance = 0.02; + + // Rotations closer than this are the same motif orientation. + private const double RotationTolerance = 1e-6; + + /// + /// The pattern the parts repeat on, or null when they do not form one. + /// A pattern must over-determine its lattice: a row needs three parts + /// of one motif group and a grid four, since any two or three points + /// trivially fit some lattice. Every group needs at least two parts. + /// + public static RepeatingPattern Detect(IReadOnlyList parts) + { + if (parts == null || parts.Count < 3 || parts.Any(p => p.BaseDrawing == null || p.BaseDrawing.IsCutOff)) + return null; + + var centers = parts.Select(p => p.BoundingBox.Center).ToArray(); + var groups = Group(parts); + if (groups.Any(g => g.Count < 2)) + return null; + + if (!TryBasis(groups, centers, out var a, out var b)) + return null; + + var rank = b.X == 0 && b.Y == 0 ? 1 : 2; + if (groups.Max(g => g.Count) < rank + 2) + return null; + + // Each group's copies pair with the first group's copies through + // its part nearest that group's origin: the reduced motif offset, + // so an interlocked partner moves with its own copy. + var origin = centers[groups[0][0]]; + var points = new Vector[parts.Count]; + foreach (var group in groups) + { + var groupOrigin = centers[group.OrderBy(i => centers[i].DistanceTo(origin)).First()]; + foreach (var index in group) + { + if (!TryCoordinates(centers[index] - groupOrigin, a, b, out var u, out var v)) + return null; + points[index] = origin + a * u + b * v; + } + } + + return new RepeatingPattern { A = a, B = b, LatticePoints = points }; + } + + private static List> Group(IReadOnlyList parts) + { + var groups = new List>(); + for (var i = 0; i < parts.Count; i++) + { + var group = groups.FirstOrDefault(g => + ReferenceEquals(parts[g[0]].BaseDrawing, parts[i].BaseDrawing) + && SameRotation(parts[g[0]].Rotation, parts[i].Rotation)); + if (group == null) + groups.Add(group = new List()); + group.Add(i); + } + return groups; + } + + private static bool SameRotation(double a, double b) + { + var difference = OpenNest.Math.Angle.NormalizeRad(a - b); + return difference < RotationTolerance || OpenNest.Math.Angle.TwoPI - difference < RotationTolerance; + } + + /// + /// The shortest translation between two parts of one group, then the + /// shortest one not parallel to it. Coincident parts are no pattern. + /// + private static bool TryBasis(List> groups, Vector[] centers, out Vector a, out Vector b) + { + a = Vector.Zero; + b = Vector.Zero; + var shortest = double.MaxValue; + foreach (var d in Translations(groups, centers)) + { + var length = Length(d); + if (length < 1e-6) + return false; + if (length < shortest) + { + shortest = length; + a = d; + } + } + + var second = double.MaxValue; + foreach (var d in Translations(groups, centers)) + { + var length = Length(d); + if (System.Math.Abs(Cross(a, d)) <= 1e-3 * shortest * length) + continue; + if (length < second) + { + second = length; + b = d; + } + } + return true; + } + + private static IEnumerable Translations(List> groups, Vector[] centers) + { + foreach (var group in groups) + for (var i = 0; i < group.Count; i++) + for (var j = i + 1; j < group.Count; j++) + yield return centers[group[j]] - centers[group[i]]; + } + + private static bool TryCoordinates(Vector d, Vector a, Vector b, out double u, out double v) + { + if (b.X == 0 && b.Y == 0) + { + // A row: on the line through a, at a whole multiple of it. + var lengthA = Length(a); + var exact = (d.X * a.X + d.Y * a.Y) / (lengthA * lengthA); + u = System.Math.Round(exact); + v = 0; + var offRow = System.Math.Abs(Cross(a, d)) / lengthA; + return offRow <= CoordinateTolerance * lengthA + && System.Math.Abs(exact - u) <= CoordinateTolerance; + } + + var det = Cross(a, b); + var uExact = Cross(d, b) / det; + var vExact = Cross(a, d) / det; + u = System.Math.Round(uExact); + v = System.Math.Round(vExact); + return System.Math.Abs(uExact - u) <= CoordinateTolerance + && System.Math.Abs(vExact - v) <= CoordinateTolerance; + } + + private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X; + + private static double Length(Vector v) => System.Math.Sqrt(v.X * v.X + v.Y * v.Y); + } + + /// + /// Spreads a selection that repeats on a lattice by stretching the + /// lattice along X, then Y, as far as the work area and every clearance + /// floor allow, so the pattern survives instead of being pushed apart + /// pair by pair. Returns null when the selection is no pattern, overlaps + /// or starts outside the work area; the pair search handles those. + /// + private static Result StretchPattern( + SeparationContext context, + List entry, + List selected, + double sMin, + Options opt, + CancellationToken token + ) + { + if (sMin <= 0 || context.WorkAreaViolations(entry).Count > 0) + return null; + var pattern = RepeatingPattern.Detect(selected); + if (pattern == null) + return null; + + // The tightest selected pair sets the floor: no selected gap closes + // below it, and gaps to other parts keep the usual floors at it. + var floor = double.MaxValue; + foreach (var pair in context.Pairs) + if (context.IsMover(pair.IndexB)) + floor = System.Math.Min(floor, pair.EntryFloor); + if (!(floor > 0) || floor == double.MaxValue) + return null; + + var stretch = new PatternStretch(context, entry, selected, pattern.LatticePoints, floor); + if (!stretch.Spread(horizontal: true, opt.Tolerance, token) + || !stretch.Spread(horizontal: false, opt.Tolerance, token)) + return new Result { Canceled = true }; + + var positions = stretch.Positions(); + if (!context.MeetsEntryFloors(positions, entry, floor)) + throw new InvalidOperationException("Pattern stretch violates an entry clearance floor."); + + double measured; + try + { + measured = MinimumPairClearance(context, positions, token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new Result { Canceled = true }; + } + if (token.IsCancellationRequested) + return new Result { Canceled = true }; + + Apply(selected, entry, positions); + return new Result + { + AchievedSpacing = System.Math.Max(0, measured), + KeptPattern = true, + PatternMoved = stretch.ScaleX > 1 || stretch.ScaleY > 1, + }; + } + + /// + /// Per-axis scale of a pattern's lattice points about their center, plus + /// the shift that keeps the stretched selection inside the work area. + /// A part moves by the displacement of its lattice point. + /// + private sealed class PatternStretch + { + private readonly SeparationContext context; + private readonly List entry; + private readonly Box[] bounds; + private readonly Vector[] lattice; + private readonly Vector center; + private readonly double floor; + + public double ScaleX = 1, ScaleY = 1; + private double shiftX, shiftY; + + public PatternStretch( + SeparationContext context, List entry, List selected, + Vector[] lattice, double floor) + { + this.context = context; + this.entry = entry; + this.lattice = lattice; + this.floor = floor; + bounds = selected.Select(p => p.BoundingBox).ToArray(); + center = new Vector( + (lattice.Min(p => p.X) + lattice.Max(p => p.X)) / 2, + (lattice.Min(p => p.Y) + lattice.Max(p => p.Y)) / 2); + } + + public List Positions() => Positions(ScaleX, shiftX, ScaleY, shiftY); + + private List Positions(double scaleX, double offsetX, double scaleY, double offsetY) + { + var positions = new List(entry); + for (var i = 0; i < lattice.Length; i++) + positions[i] = new Vector( + entry[i].X + (scaleX - 1) * (lattice[i].X - center.X) + offsetX, + entry[i].Y + (scaleY - 1) * (lattice[i].Y - center.Y) + offsetY); + return positions; + } + + /// + /// True when no selected part passes through a fixed part, cut-off or + /// clamp on its straight way from the entry layout, so a pattern never + /// spreads to the far side of material it started beside. + /// + private bool Unobstructed(List positions) + { + if (!context.HasWalls) + return true; + for (var i = 0; i < lattice.Length; i++) + { + var move = positions[i] - entry[i]; + var distance = System.Math.Sqrt(move.X * move.X + move.Y * move.Y); + if (distance <= 1e-9) + continue; + var room = context.ClipToMaterial(i, entry, move / distance, distance, wallsOnly: true); + if (room < distance - 1e-6) + return false; + } + return true; + } + + private enum Placement { Center, Low, High } + + /// + /// Finds the largest scale along one axis whose stretched selection + /// fits the work area and keeps every floor, trying the selection + /// centered, then held at its low and high edges. Returns false when + /// canceled. + /// + public bool Spread(bool horizontal, double tolerance, CancellationToken token) + { + double Of(Vector v) => horizontal ? v.X : v.Y; + double LowOf(Box box) => horizontal ? box.Left : box.Bottom; + double HighOf(Box box) => horizontal ? box.Right : box.Top; + + var mid = Of(center); + var reach = lattice.Max(Of) - lattice.Min(Of); + if (reach <= tolerance) + return true; // one row or column along this axis + + var area = context.WorkArea; + var areaLow = LowOf(area); + var areaHigh = HighOf(area); + var entryLow = bounds.Min(LowOf); + var entryHigh = bounds.Max(HighOf); + + // Stretched extent with no shift; null when it is wider than the area. + (double Low, double High)? Extent(double scale) + { + var low = double.MaxValue; + var high = double.MinValue; + for (var i = 0; i < bounds.Length; i++) + { + var move = (scale - 1) * (Of(lattice[i]) - mid); + low = System.Math.Min(low, LowOf(bounds[i]) + move); + high = System.Math.Max(high, HighOf(bounds[i]) + move); + } + return high - low <= areaHigh - areaLow ? (low, high) : null; + } + + double ShiftFor(double scale, Placement placement) + { + var (low, high) = Extent(scale).Value; + var shift = placement switch + { + Placement.Low => entryLow - low, + Placement.High => entryHigh - high, + _ => (entryLow + entryHigh - low - high) / 2, + }; + return System.Math.Clamp(shift, areaLow - low, areaHigh - high); + } + + bool Valid(double scale, Placement placement) + { + var shift = ShiftFor(scale, placement); + var positions = horizontal + ? Positions(scale, shift, ScaleY, shiftY) + : Positions(ScaleX, shiftX, scale, shift); + return context.MeetsEntryFloors(positions, entry, floor) && Unobstructed(positions); + } + + // The stretched extent is at least (scale - 1) * reach, so this + // upper bound never fits; the extent is convex in the scale. + var step = tolerance / reach; + var fits = 1.0; + var wide = 1 + (areaHigh - areaLow) / reach; + while (wide - fits > step) + { + var probe = (fits + wide) / 2; + if (Extent(probe) != null) + fits = probe; + else + wide = probe; + } + if (fits <= 1 + step) + return true; + + var bestScale = 1.0; + var bestPlacement = Placement.Center; + foreach (var placement in new[] { Placement.Center, Placement.Low, Placement.High }) + { + if (token.IsCancellationRequested) + return false; + if (Valid(fits, placement)) + { + bestScale = fits; + bestPlacement = placement; + break; + } + + // Scale 1 is the entry layout, which meets every floor. + var good = 1.0; + var bad = fits; + while (bad - good > step) + { + if (token.IsCancellationRequested) + return false; + var probe = (good + bad) / 2; + if (Valid(probe, placement)) + good = probe; + else + bad = probe; + } + if (good > bestScale) + { + bestScale = good; + bestPlacement = placement; + } + } + + if (bestScale <= 1) + return true; + var bestShift = ShiftFor(bestScale, bestPlacement); + if (horizontal) + { + ScaleX = bestScale; + shiftX = bestShift; + } + else + { + ScaleY = bestScale; + shiftY = bestShift; + } + return true; + } + } + } +} diff --git a/OpenNest/Controls/SelectionManager.cs b/OpenNest/Controls/SelectionManager.cs index 44b55975..6c814e7d 100644 --- a/OpenNest/Controls/SelectionManager.cs +++ b/OpenNest/Controls/SelectionManager.cs @@ -254,9 +254,12 @@ namespace OpenNest.Controls view.Invalidate(); - view.Status = - $"Expanded spacing to {result.AchievedSpacing:0.###}" - + (result.Violations.Count > 0 ? $" - {result.Violations.Count} blocked pairs" : ""); + view.Status = result.KeptPattern + ? (result.PatternMoved + ? $"Spread the repeating pattern - smallest gap {result.AchievedSpacing:0.###}" + : $"The repeating pattern has no room to spread - smallest gap {result.AchievedSpacing:0.###}") + : $"Expanded spacing to {result.AchievedSpacing:0.###}" + + (result.Violations.Count > 0 ? $" - {result.Violations.Count} blocked pairs" : ""); change.Complete(); return true; diff --git a/docs/geometry/expand-spacing.md b/docs/geometry/expand-spacing.md index 540a00cf..b8b43b4f 100644 --- a/docs/geometry/expand-spacing.md +++ b/docs/geometry/expand-spacing.md @@ -2,6 +2,12 @@ `Plate > Expand Spacing` moves selected parts by translation only. The first selected part is the anchor for selected pairs; the later selected member moves, and nonselected parts remain fixed. The plate retains its own `EdgeSpacing` floor; the command does not change `PartSpacing` or `EdgeSpacing`. +## Repeating patterns + +A selection that repeats on a lattice, such as a fill, is stretched as one pattern instead of going through the pair search below. Parts of one drawing at one rotation form a motif group, and every group must sit on the same two lattice translations (one for a single row) to within 2% of a pitch. Each group needs at least two parts, and one group needs at least three for a row or four for a grid, because any two or three points fit some lattice. Cut-offs, overlapping selections and selections that start outside the work area always use the pair search. + +The lattice is scaled along X, then along Y, about its center, and each part moves by the displacement of its motif copy's lattice point, so interlocked pairs and other multi-part motifs stay rigid. Along each axis the largest scale is used whose stretched selection fits the work area and keeps every floor: no selected gap closes below the smallest selected gap at entry, a gap to a nonselected part closes no further than that, and cut-offs and clamps keep their entry gaps. A part may not pass through a nonselected part, cut-off or clamp on its straight way from the entry layout. The stretched selection is tried centered on where it was, then held at its low edge, then at its high edge. An axis with no room keeps its pitch, so a fill whose rows run edge to edge along X only opens its rows along Y: the row gap stays and the diagonal gap grows. The status bar reads "Spread the repeating pattern" with the smallest gap, or says the pattern has no room to spread. + Every selected/selected and selected/nonselected pair has a clearance floor, measured against material with cutouts subtracted. A pair of parts may close to the spacing being tried but never below its own starting gap when that gap is already smaller, so a wide gap can be spent evenly across a layout and a tight one cannot shrink. A cut-off or clamp always keeps its full starting gap. A later move may reuse slack opened by an earlier move. Existing overlaps are accepted for attempted separation; a remaining overlap is reported as a violation, not a successful positive gap. Geometry whose material side is unresolved at sub-scale is conservatively reported as a violation when native extents intersect; that negative marker is not a measured penetration depth. An unchanged selected part already outside the work area also yields an edge violation and zero credited spacing. Temporarily blocked pairs are retried after another part moves, while a pass without positive movement stops the relaxation. An individual violation's `BlockedByEdge` flag is true only when the work-area edge is the first limiting cause; the desktop status currently displays a blocked-pair count, not those individual causes. Probes run on scratch poses. Each trial translation is clipped against blocking material along its entire straight path, including cutout walls and regenerated open cut-off paths; a remote cut-off does not pin an unrelated move. Entry floors for open cut-offs use nearest points on every native segment, not a program endpoint or a fictional closing chord. Open paths that strictly enter part material stay as violations even at zero target; boundary contact and paths wholly inside holes remain legal. An open path's broad box is not a blocker when its real segments miss the part. Initially overlapping convex line profiles and complete native disks can take a certified direct separating path, including a safe partial move when the plate edge prevents a full exit; unsupported or ambiguous overlap paths stay put and remain reported. Complete native disks use analytic clearance rather than interpreting a shallow arc overlap as a positive polygon gap. For other curved pairs, a polygon gap inside the combined 0.002-unit chord band is uncertain and conservatively reported as a violation rather than proof of clear material. When a part touches a cutout wall and the usual nearest-boundary direction makes no safe progress, the tool tries the opposite direction through the same checks. Tiny clamp keep-outs use tolerance-free material occupancy and a conservative native-bound motion limit when tolerant geometry cannot certify them. Cancellation before application leaves the live layout unchanged; `Separate` never changes it. A bounded search reports the spacing it found, not an optimum. See [directional slides](directional-slides.md) for directional contact behavior.