diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularPairPoolTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularPairPoolTests.cs new file mode 100644 index 00000000..535f0602 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularPairPoolTests.cs @@ -0,0 +1,276 @@ +using OpenNest.Engine.BestFit; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.NestingEngines.Irregular; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; +using static OpenNest.Engine.Tests.NestingEngines.Shapes; +using Irregular = OpenNest.Engine.NestingEngines.Irregular; + +namespace OpenNest.Engine.Tests.NestingEngines; + +// The best-fit pool is process-wide state and these tests swap the evaluator factory and the +// pool capacity, so they run alone. +[CollectionDefinition(nameof(BestFitPoolCollection), DisableParallelization = true)] +public class BestFitPoolCollection { } + +/// +/// Best-fit candidates for job geometry are pooled by content, so a repeated or re-tuned job of +/// the same parts (fresh snapshots, fresh engine instances) does not search again and places +/// the parts exactly as the first job did. +/// +[Collection(nameof(BestFitPoolCollection))] +public class IrregularPairPoolTests +{ + private const double Spacing = 0.25; + + /// Counts best-fit searches of one geometry by wrapping the evaluator factory. + private sealed class SearchSpy : IDisposable + { + private readonly Func? previous; + private readonly PartGeometrySnapshot geometry; + private int count; + + public SearchSpy(OpenNest.CNC.Program program) + { + geometry = PartGeometrySnapshot.FromProgram(program); + previous = BestFitCache.CreateEvaluator; + BestFitCache.CreateEvaluator = (drawing, _) => + { + if (PartGeometrySnapshot.ContentComparer.Equals( + PartGeometrySnapshot.FromProgram(drawing.Program), geometry)) + Interlocked.Increment(ref count); + return new PairEvaluator(); + }; + } + + public int Count => Volatile.Read(ref count); + + public void Dispose() => BestFitCache.CreateEvaluator = previous!; + } + + private sealed class FailingEvaluator : IPairEvaluator + { + public List EvaluateAll(List candidates) => + throw new InvalidOperationException("search failed"); + } + + // Each test uses its own geometry so pooled sets from other tests cannot satisfy it. + private static OpenNest.CNC.Program Wedge(double top) => Polyline((0, 0), (8.3, 0), (top, 20.7), (0, 20.7)); + + private static (NestJob Job, IReadOnlyList Types) FreshJob(OpenNest.CNC.Program program, int quantity) + { + var job = Job(new[] { Part("wedge", program, quantity, RotationPolicy.Automatic) }, + new[] { Stock("sheet", 17.3, 31.7, spacing: Spacing) }); + return (job, PartCatalog.Build(job)); + } + + private static List<(double, double, double, double, double)> Poses(IReadOnlyDictionary> pairs) => + pairs.SelectMany(kv => kv.Value.Select(p => (p.A.Rotation, p.B.Rotation, p.Dx, p.Dy, (double)kv.Key))).ToList(); + + [Fact] + public void PairCatalog_SecondJobOfTheSameGeometry_SkipsTheSearchAndOffersTheSamePairs() + { + BestFitCache.Clear(); + var program = Wedge(5.9); + using var spy = new SearchSpy(program); + + var (first, firstTypes) = FreshJob(program, 2); + var firstPairs = PairCatalog.Build(firstTypes, Spacing, 31.7, 17.3, CancellationToken.None); + Assert.NotEmpty(firstPairs[0]); + Assert.Equal(1, spy.Count); + Assert.True(BestFitCache.IsShared(first.Parts[0].Geometry, Spacing)); + + var (second, secondTypes) = FreshJob(program, 2); + Assert.NotSame(first.Parts[0].Geometry, second.Parts[0].Geometry); + var secondPairs = PairCatalog.Build(secondTypes, Spacing, 31.7, 17.3, CancellationToken.None); + + Assert.Equal(1, spy.Count); + Assert.Equal(Poses(firstPairs), Poses(secondPairs)); + } + + [Fact] + public void IrregularEngine_FreshInstanceAndJob_PlacesIdenticallyWithoutSearchingAgain() + { + BestFitCache.Clear(); + var program = Wedge(6.1); + using var spy = new SearchSpy(program); + + var (first, _) = FreshJob(program, 4); + var firstResult = new IrregularNestingEngine().Solve(first); + Assert.Equal(NestJobStatus.Complete, firstResult.Status); + LayoutAssert.Valid(first, firstResult); + Assert.Equal(1, spy.Count); + + var (second, _) = FreshJob(program, 4); + var secondResult = new IrregularNestingEngine().Solve(second); + + Assert.Equal(1, spy.Count); + Assert.Equal(Placements(firstResult), Placements(secondResult)); + } + + [Fact] + public void PairCatalog_ReusesTheSearchOnlyWhenTheSpacingMatches() + { + BestFitCache.Clear(); + var program = Wedge(6.3); + using var spy = new SearchSpy(program); + + var (job, types) = FreshJob(program, 2); + PairCatalog.Build(types, Spacing, 31.7, 17.3, CancellationToken.None); + PairCatalog.Build(types, Spacing, 31.7, 17.3, CancellationToken.None); + Assert.Equal(1, spy.Count); + Assert.False(BestFitCache.IsShared(job.Parts[0].Geometry, 0.5)); + + PairCatalog.Build(types, 0.5, 31.7, 17.3, CancellationToken.None); + Assert.Equal(2, spy.Count); + Assert.True(BestFitCache.IsShared(job.Parts[0].Geometry, 0.5)); + } + + [Fact] + public void SharedCapacity_DropsTheLeastRecentlyUsedGeometryFirst() + { + BestFitCache.Clear(); + var capacity = BestFitCache.SharedCapacity; + try + { + BestFitCache.SharedCapacity = 2; + var programs = new[] { Wedge(6.5), Wedge(6.7), Wedge(6.9) }; + var parts = programs.Select(p => Part("w", p, 2, RotationPolicy.Automatic)).ToArray(); + + Compute(parts[0]); + Compute(parts[1]); + Compute(parts[0]); // The first becomes the most recently used. + Compute(parts[2]); + + Assert.True(BestFitCache.IsShared(parts[0].Geometry, Spacing)); + Assert.False(BestFitCache.IsShared(parts[1].Geometry, Spacing)); + Assert.True(BestFitCache.IsShared(parts[2].Geometry, Spacing)); + + using var spy = new SearchSpy(programs[1]); + Compute(parts[1]); + Assert.Equal(1, spy.Count); + } + finally + { + BestFitCache.SharedCapacity = capacity; + } + } + + [Fact] + public void SharedCapacityZero_DisablesPoolingWithoutBreakingTheSearch() + { + BestFitCache.Clear(); + var capacity = BestFitCache.SharedCapacity; + try + { + BestFitCache.SharedCapacity = 0; + var program = Wedge(7.1); + var part = Part("w", program, 2, RotationPolicy.Automatic); + using var spy = new SearchSpy(program); + Assert.NotEmpty(Compute(part)); + Assert.False(BestFitCache.IsShared(part.Geometry, Spacing)); + Assert.NotEmpty(Compute(part)); + Assert.Equal(2, spy.Count); + } + finally + { + BestFitCache.SharedCapacity = capacity; + } + } + + [Fact] + public void Clear_DropsThePool() + { + BestFitCache.Clear(); + var program = Wedge(7.3); + var part = Part("w", program, 2, RotationPolicy.Automatic); + using var spy = new SearchSpy(program); + Compute(part); + Assert.True(BestFitCache.IsShared(part.Geometry, Spacing)); + + BestFitCache.Clear(); + + Assert.False(BestFitCache.IsShared(part.Geometry, Spacing)); + Compute(part); + Assert.Equal(2, spy.Count); + } + + [Fact] + public void FailedSearch_IsNotPooled() + { + BestFitCache.Clear(); + var program = Wedge(7.5); + var part = Part("w", program, 2, RotationPolicy.Automatic); + var previous = BestFitCache.CreateEvaluator; + try + { + BestFitCache.CreateEvaluator = (_, _) => new FailingEvaluator(); + Assert.Throws(() => Compute(part)); + Assert.False(BestFitCache.IsShared(part.Geometry, Spacing)); + } + finally + { + BestFitCache.CreateEvaluator = previous!; + } + + Assert.NotEmpty(Compute(part)); + Assert.True(BestFitCache.IsShared(part.Geometry, Spacing)); + } + + [Fact] + public void ShareByGeometry_LapsesWhenTheProgramIsReplaced() + { + BestFitCache.Clear(); + var program = Wedge(7.7); + var part = Part("w", program, 2, RotationPolicy.Automatic); + using var spy = new SearchSpy(program); + var drawing = DrawingJobMapper.CreateDrawing(part); + BestFitCache.GetOrCompute(drawing, 31.7, 17.3, Spacing); + Assert.Equal(1, spy.Count); + + drawing.Program = (OpenNest.CNC.Program)drawing.Program.Clone(); + BestFitCache.GetOrCompute(drawing, 31.7, 17.3, Spacing); + + // The drawing no longer vouches for the geometry, so it searches on its own. + Assert.Equal(2, spy.Count); + } + + [Fact] + public void ContentComparer_MatchesExactMotionsOnly() + { + var comparer = PartGeometrySnapshot.ContentComparer; + var a = PartGeometrySnapshot.FromProgram(Wedge(7.9)); + var b = PartGeometrySnapshot.FromProgram(Wedge(7.9)); + var c = PartGeometrySnapshot.FromProgram(Wedge(7.9 + 1e-12)); + + Assert.NotSame(a, b); + Assert.False(a.Equals(b)); + Assert.True(comparer.Equals(a, b)); + Assert.Equal(comparer.GetHashCode(a), comparer.GetHashCode(b)); + Assert.False(comparer.Equals(a, c)); + + var zero = PartGeometrySnapshot.FromProgram(Polyline((0, 0), (5, 0), (5, 5))); + var negativeZero = PartGeometrySnapshot.FromProgram(Polyline((-0.0, 0), (5, 0), (5, 5))); + Assert.False(comparer.Equals(zero, negativeZero)); + + var suppressed = Wedge(7.9); + ((OpenNest.CNC.Motion)suppressed.Codes[1]).Suppressed = true; + Assert.False(comparer.Equals(a, PartGeometrySnapshot.FromProgram(suppressed))); + } + + private static List Compute(NestJobPart part) + { + var drawing = DrawingJobMapper.CreateDrawing(part); + try + { + return BestFitCache.GetOrCompute(drawing, 31.7, 17.3, Spacing); + } + finally + { + BestFitCache.Invalidate(drawing); + } + } + + private static List<(string, double, double, double)> Placements(NestJobResult result) => + result.Plates.SelectMany(p => p.Placements.Select(q => (q.PartId, q.X, q.Y, q.Rotation))).ToList(); +} diff --git a/OpenNest.Engine/BestFit/BestFitCache.cs b/OpenNest.Engine/BestFit/BestFitCache.cs index 3b93d910..b1faa747 100644 --- a/OpenNest.Engine/BestFit/BestFitCache.cs +++ b/OpenNest.Engine/BestFit/BestFitCache.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using OpenNest.Engine.Jobs; namespace OpenNest.Engine.BestFit { @@ -13,6 +14,14 @@ namespace OpenNest.Engine.BestFit /// shares them and closed nests or finished solves release theirs. Candidates are computed once /// per spacing and filtered per plate size. An entry is dropped when the drawing's /// instance or canonical angle changes. + /// + /// A drawing reconstructed from job geometry () draws its + /// candidates from a process-wide pool keyed by the geometry's exact content and the spacing, + /// so every later job of the same geometry, in any engine instance or Auto Nest run, skips the + /// search. Candidates depend on the program geometry and spacing alone, so a pooled set is + /// the set the search would produce. The pool keeps the most + /// recently used sets and outlives the drawings; never touches it. + /// /// public static class BestFitCache { @@ -21,9 +30,61 @@ namespace OpenNest.Engine.BestFit private static readonly ConditionalWeakTable _entries = new(); private static readonly object _entriesLock = new(); + private static readonly ConditionalWeakTable _geometry = new(); + private static readonly object _poolLock = new(); + private static readonly LinkedList _poolOrder = new(); + private static readonly Dictionary<(PartGeometrySnapshot Geometry, double Spacing), LinkedListNode> + _pool = new(new PoolKeyComparer()); + private static int _sharedCapacity = 512; + public static Func CreateEvaluator { get; set; } public static Func CreateSlideComputer { get; set; } + /// + /// Candidate sets (one per geometry and spacing) the process-wide pool keeps; the least + /// recently used set is dropped first. Zero disables pooling for later computations. + /// + public static int SharedCapacity + { + get => Volatile.Read(ref _sharedCapacity); + set + { + if (value < 0) + throw new ArgumentOutOfRangeException(nameof(value)); + lock (_poolLock) + { + _sharedCapacity = value; + Trim(); + } + } + } + + /// + /// Declares an exact reconstruction of + /// (its program unrotated, as DrawingJobMapper.ToProgram builds it). Its unfiltered + /// candidates then come from, and go to, the process-wide pool for that geometry content + /// and spacing. The declaration lapses if the drawing's + /// instance is replaced. + /// + public static void ShareByGeometry(Drawing drawing, PartGeometrySnapshot geometry) + { + ArgumentNullException.ThrowIfNull(drawing); + ArgumentNullException.ThrowIfNull(geometry); + _geometry.AddOrUpdate(drawing, new SharedGeometry(geometry, drawing.Program)); + } + + /// + /// True when the pool holds computed candidates for this geometry content and spacing, so + /// a drawing sharing it would skip the search. + /// + public static bool IsShared(PartGeometrySnapshot geometry, double spacing) + { + ArgumentNullException.ThrowIfNull(geometry); + lock (_poolLock) + return _pool.TryGetValue((geometry, spacing), out var node) + && node.Value.Candidates.IsValueCreated; + } + public static List GetOrCompute( Drawing drawing, double plateWidth, @@ -120,10 +181,16 @@ namespace OpenNest.Engine.BestFit return result; } + /// Drops every drawing's entry and the shared pool. public static void Clear() { lock (_entriesLock) _entries.Clear(); + lock (_poolLock) + { + _pool.Clear(); + _poolOrder.Clear(); + } } private static Entry GetEntry(Drawing drawing) @@ -146,12 +213,17 @@ namespace OpenNest.Engine.BestFit private static List GetCandidates(Entry entry, Drawing drawing, double spacing) { + var shared = SharedGeometryOf(CanonicalFrame.SourceOf(drawing)); var lazy = entry.Unfiltered.GetOrAdd( spacing, - _ => new Lazy>( - () => ComputeCandidates(drawing, spacing), - LazyThreadSafetyMode.ExecutionAndPublication - ) + _ => + { + var computation = new Lazy>( + () => ComputeCandidates(drawing, spacing), + LazyThreadSafetyMode.ExecutionAndPublication + ); + return shared == null ? computation : PoolGetOrAdd(shared, spacing, computation); + } ); try @@ -162,10 +234,72 @@ namespace OpenNest.Engine.BestFit { // Don't cache the failure; the next caller retries. entry.Unfiltered.TryRemove(new KeyValuePair>>(spacing, lazy)); + if (shared != null) + PoolRemove(shared, spacing, lazy); throw; } } + private static PartGeometrySnapshot SharedGeometryOf(Drawing source) + { + if (source == null || !_geometry.TryGetValue(source, out var shared)) + return null; + // A replaced program no longer matches the declared geometry. + return ReferenceEquals(shared.Program, source.Program) ? shared.Geometry : null; + } + + /// The pooled computation for this geometry and spacing, adding + /// when there is none; a hit becomes most recently used. + private static Lazy> PoolGetOrAdd( + PartGeometrySnapshot geometry, + double spacing, + Lazy> computation + ) + { + lock (_poolLock) + { + var key = (geometry, spacing); + if (_pool.TryGetValue(key, out var node)) + { + _poolOrder.Remove(node); + _poolOrder.AddFirst(node); + return node.Value.Candidates; + } + + if (_sharedCapacity == 0) + return computation; + + node = _poolOrder.AddFirst(new PoolEntry(geometry, spacing, computation)); + _pool[key] = node; + Trim(); + return computation; + } + } + + private static void PoolRemove(PartGeometrySnapshot geometry, double spacing, Lazy> computation) + { + lock (_poolLock) + { + var key = (geometry, spacing); + if (_pool.TryGetValue(key, out var node) && ReferenceEquals(node.Value.Candidates, computation)) + { + _pool.Remove(key); + _poolOrder.Remove(node); + } + } + } + + // Caller holds _poolLock. + private static void Trim() + { + while (_poolOrder.Count > _sharedCapacity) + { + var last = _poolOrder.Last; + _pool.Remove((last.Value.Geometry, last.Value.Spacing)); + _poolOrder.RemoveLast(); + } + } + private static List ComputeCandidates(Drawing drawing, double spacing) { // Operate on the canonical frame so cached pair positions are orientation-invariant. @@ -261,5 +395,28 @@ namespace OpenNest.Engine.BestFit ReferenceEquals(_program, source.Program) && _sourceAngle == (source.Source?.Angle ?? 0.0); } + + /// The geometry a drawing was reconstructed from, and the program built then. + private sealed record SharedGeometry(PartGeometrySnapshot Geometry, CNC.Program Program); + + private sealed record PoolEntry( + PartGeometrySnapshot Geometry, + double Spacing, + Lazy> Candidates + ); + + private sealed class PoolKeyComparer + : IEqualityComparer<(PartGeometrySnapshot Geometry, double Spacing)> + { + public bool Equals( + (PartGeometrySnapshot Geometry, double Spacing) x, + (PartGeometrySnapshot Geometry, double Spacing) y + ) => + x.Spacing.Equals(y.Spacing) + && PartGeometrySnapshot.ContentComparer.Equals(x.Geometry, y.Geometry); + + public int GetHashCode((PartGeometrySnapshot Geometry, double Spacing) key) => + HashCode.Combine(PartGeometrySnapshot.ContentComparer.GetHashCode(key.Geometry), key.Spacing); + } } } diff --git a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs index 182e6291..fc3b1ca5 100644 --- a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs +++ b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using OpenNest.CNC; +using OpenNest.Engine.BestFit; namespace OpenNest.Engine.Jobs.Adapters; @@ -90,6 +91,9 @@ public static class DrawingJobMapper return program; } + /// A private drawing of the part for placement code. Its best-fit candidates are + /// pooled by geometry content (), so the same part + /// geometry in a later job, engine instance or Auto Nest run does not search again. internal static Drawing CreateDrawing(NestJobPart part) { var drawing = new Drawing(part.Id, ToProgram(part.Geometry)) { Priority = part.Priority }; @@ -101,6 +105,7 @@ public static class DrawingJobMapper EndAngle = part.Rotation.End, Allow180Equivalent = part.Rotation.Allow180Equivalent, }; + BestFitCache.ShareByGeometry(drawing, part.Geometry); return drawing; } diff --git a/OpenNest.Engine/Jobs/PartGeometrySnapshot.cs b/OpenNest.Engine/Jobs/PartGeometrySnapshot.cs index fd4e5b11..30fb4e3a 100644 --- a/OpenNest.Engine/Jobs/PartGeometrySnapshot.cs +++ b/OpenNest.Engine/Jobs/PartGeometrySnapshot.cs @@ -30,9 +30,73 @@ public sealed class PartGeometrySnapshot Motions = NestJob.Own(motions); } + private int? contentHash; + public Mode Mode { get; } public IReadOnlyList Motions { get; } + /// + /// Equality by exact content: the same mode and the same motions in the same order, every + /// value bit for bit (so 0.0 and -0.0 differ), whatever the instances. Snapshots themselves + /// still compare by reference, as job identity does; this comparer is for caches whose + /// results depend on the geometry alone, such as best-fit candidates. + /// + public static IEqualityComparer ContentComparer { get; } = new ContentEquality(); + + private int ContentHash + { + get + { + if (contentHash is int cached) + return cached; + var hash = new HashCode(); + hash.Add(Mode); + foreach (var motion in Motions) + { + hash.Add(motion.Type); + hash.Add(Bits(motion.X)); + hash.Add(Bits(motion.Y)); + hash.Add(Bits(motion.CenterX)); + hash.Add(Bits(motion.CenterY)); + hash.Add(motion.Rotation); + hash.Add(motion.Layer); + hash.Add(motion.Suppressed); + } + var value = hash.ToHashCode(); + contentHash = value; + return value; + } + } + + private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value); + + private static bool SameMotion(PartGeometryMotion a, PartGeometryMotion b) => + a.Type == b.Type + && Bits(a.X) == Bits(b.X) + && Bits(a.Y) == Bits(b.Y) + && Bits(a.CenterX) == Bits(b.CenterX) + && Bits(a.CenterY) == Bits(b.CenterY) + && a.Rotation == b.Rotation + && a.Layer == b.Layer + && a.Suppressed == b.Suppressed; + + private sealed class ContentEquality : IEqualityComparer + { + public bool Equals(PartGeometrySnapshot x, PartGeometrySnapshot y) + { + if (ReferenceEquals(x, y)) + return true; + if (x is null || y is null || x.Mode != y.Mode || x.Motions.Count != y.Motions.Count) + return false; + for (var i = 0; i < x.Motions.Count; i++) + if (!SameMotion(x.Motions[i], y.Motions[i])) + return false; + return true; + } + + public int GetHashCode(PartGeometrySnapshot snapshot) => snapshot.ContentHash; + } + /// Copies supported motion geometry immediately; later program edits cannot affect this snapshot. public static PartGeometrySnapshot FromProgram(Program program) { diff --git a/OpenNest.Engine/NestingEngines/Irregular/PairCatalog.cs b/OpenNest.Engine/NestingEngines/Irregular/PairCatalog.cs index a46ecc41..97774a50 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/PairCatalog.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/PairCatalog.cs @@ -95,8 +95,9 @@ internal static class PairCatalog Func? drawingFor ) { - var drawing = drawingFor?.Invoke(type.Part) - ?? new Drawing(type.Part.Id, DrawingJobMapper.ToProgram(type.Part.Geometry)); + // Either drawing reads its candidates from the geometry-content pool, so a repeated + // or re-tuned job of the same parts skips the best-fit search. + var drawing = drawingFor?.Invoke(type.Part) ?? DrawingJobMapper.CreateDrawing(type.Part); List fits; try { diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index e132cfeb..5fb40528 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -81,8 +81,17 @@ Any remaining numerical hole is filled only when its entire ring is certified to space, preserving genuine enclosed placement pockets without changing spacing tolerances. The pairs and the Fill blocks below read each part through one private drawing per part -geometry, owned by the engine instance, so a part's best-fit search runs once per instance: -pairs and blocks share it, and so do a pipeline's cutout trial and its ordinary solve. +geometry, owned by the engine instance: pairs and blocks share it, and so do a pipeline's cutout +trial and its ordinary solve. The best-fit search itself runs once per part geometry and spacing +in the whole process: every drawing reconstructed from job geometry +(`DrawingJobMapper.CreateDrawing`) takes its unfiltered candidates from a pool in `BestFitCache` +keyed by the snapshot's exact content (mode and every motion value, bit for bit) and the spacing, +so a repeated or re-tuned Auto Nest of the same drawings, a fresh engine instance, another engine +or the cutout pre-pass skips the search and offers the same pairs. Candidates depend on the program +geometry and spacing alone, so a pooled set is the set the search would produce and placements are +unchanged. The pool keeps the 512 most recently used sets (`BestFitCache.SharedCapacity`), outlives +the private drawings (`Invalidate` releases a drawing's entry, not the pool) and is in-memory only: +the sets saved in a `.nest` file belong to the caller's drawings and are not consulted here. When remaining demand exceeds two, Irregular also offers Fill patterns as optional multi-member candidates, not as solid bounding boxes or a whole-job Fill fallback. It searches