diff --git a/OpenNest.Core/Diagnostics/OverlapMaterialCache.cs b/OpenNest.Core/Diagnostics/OverlapMaterialCache.cs
new file mode 100644
index 0000000..6f23fd2
--- /dev/null
+++ b/OpenNest.Core/Diagnostics/OverlapMaterialCache.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using OpenNest.CNC;
+using OpenNest.Geometry;
+
+namespace OpenNest.Diagnostics;
+
+///
+/// Reuses each clean drawing program's converted entities and prepared (validated, chorded,
+/// triangulation-checked) material across overlap requests. Preparation dominates the cost of
+/// drawings with many holes, and it depends only on the drawing, not on where parts sit, so a
+/// recheck after moving parts only repeats the cheap pose transforms and pair clipping.
+/// Entries are keyed by reference and released with it. A changed code
+/// count or program rotation is detected, but that is not a geometry hash: call
+/// before any in-place edit of a clean program or its hole subprograms.
+/// Capture on one thread at a time; prepared entries may be shared by concurrent analyses.
+///
+public sealed class OverlapMaterialCache
+{
+ private readonly ConditionalWeakTable sources = new();
+
+ public void Clear() => sources.Clear();
+
+ internal OverlapSource Get(Program program, Func create)
+ {
+ if (sources.TryGetValue(program, out var source) && source.Matches(program))
+ return source;
+ source = create(program);
+ sources.AddOrUpdate(program, source);
+ return source;
+ }
+}
+
+/// Owned converted entities for one clean program, plus its lazily prepared material.
+internal sealed class OverlapSource
+{
+ private readonly object gate = new();
+ private readonly int codeCount;
+ private readonly long rotation;
+ private PreparedMaterial prepared;
+
+ internal OverlapSource(Program program, List entities, string error)
+ {
+ codeCount = program.Codes.Count;
+ rotation = BitConverter.DoubleToInt64Bits(program.Rotation);
+ Entities = entities;
+ Error = error;
+ }
+
+ /// Never mutated; preparation clones before chaining.
+ internal List Entities { get; }
+ internal string Error { get; }
+
+ internal bool Matches(Program program) =>
+ program.Codes.Count == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation;
+
+ ///
+ /// Prepares once and shares the result. A geometry failure is cached like a success;
+ /// cancellation is not, so a superseded request cannot poison the next one.
+ ///
+ internal PreparedMaterial Prepare(CancellationToken cancellationToken)
+ {
+ var current = Volatile.Read(ref prepared);
+ if (current != null)
+ return current;
+ lock (gate)
+ {
+ if (prepared != null)
+ return prepared;
+ PreparedMaterial result;
+ try
+ {
+ result = new PreparedMaterial(OverlapMaterial.Read(Entities, cancellationToken), null);
+ }
+ catch (Exception exception) when (PlateOverlapAnalyzer.IsGeometryFailure(exception))
+ {
+ result = new PreparedMaterial(null, exception.Message);
+ }
+ Volatile.Write(ref prepared, result);
+ return result;
+ }
+ }
+}
+
+/// Validated local-frame material, only ever read (transformed into new polygons).
+internal sealed record PreparedMaterial(OverlapMaterial Material, string Error);
diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
index 3692b69..410b6a2 100644
--- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs
@@ -21,13 +21,22 @@ public static class PlateOverlapAnalyzer
/// Inputs must not change during capture. Later analysis never reads live domain objects.
///
public static PlateOverlapSnapshot Capture(IReadOnlyList parts,
+ CancellationToken cancellationToken = default) =>
+ Capture(parts, new OverlapMaterialCache(), cancellationToken);
+
+ ///
+ /// As , but reuses converted
+ /// and prepared drawing material from across requests. Clear the
+ /// cache before any in-place clean-program edit (see ).
+ ///
+ public static PlateOverlapSnapshot Capture(IReadOnlyList parts, OverlapMaterialCache cache,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(parts);
+ ArgumentNullException.ThrowIfNull(cache);
cancellationToken.ThrowIfCancellationRequested();
var captured = new List();
var issues = new List();
- var sources = new Dictionary(ReferenceEqualityComparer.Instance);
for (var id = 0; id < parts.Count; id++)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -43,28 +52,11 @@ public static class PlateOverlapAnalyzer
var location = part.Location;
if (!double.IsFinite(rotation) || !OverlapMaterial.IsFinite(location))
throw new ArgumentException("Part pose must be finite.");
- if (!sources.TryGetValue(program, out var source))
- {
- try
- {
- ValidateProgram(program, new HashSet(ReferenceEqualityComparer.Instance));
- // Conversion creates fresh geometry, including expanded shared hole calls;
- // no cloning/rotation of a live program or subprogram is necessary.
- source = new CapturedSource(ConvertProgram.ToGeometry(program)
- .Where(entity => SpecialLayers.IsMaterial(entity.Layer)
- && entity.Layer != SpecialLayers.Leadin
- && entity.Layer != SpecialLayers.Leadout).ToList(), null);
- }
- catch (Exception exception) when (IsGeometryFailure(exception))
- {
- source = new CapturedSource(null, exception.Message);
- }
- sources.Add(program, source);
- }
+ var source = cache.Get(program, CaptureSource);
if (source.Error != null)
throw new ArgumentException(source.Error);
captured.Add(new CapturedOverlapPart(id, part.BaseDrawing.Name,
- source.Entities, rotation, location));
+ source, rotation, location));
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
@@ -75,6 +67,24 @@ public static class PlateOverlapAnalyzer
return new PlateOverlapSnapshot(captured, issues);
}
+ private static OverlapSource CaptureSource(Program program)
+ {
+ try
+ {
+ ValidateProgram(program, new HashSet(ReferenceEqualityComparer.Instance));
+ // Conversion creates fresh geometry, including expanded shared hole calls;
+ // no cloning/rotation of a live program or subprogram is necessary.
+ return new OverlapSource(program, ConvertProgram.ToGeometry(program)
+ .Where(entity => SpecialLayers.IsMaterial(entity.Layer)
+ && entity.Layer != SpecialLayers.Leadin
+ && entity.Layer != SpecialLayers.Leadout).ToList(), null);
+ }
+ catch (Exception exception) when (IsGeometryFailure(exception))
+ {
+ return new OverlapSource(program, null, exception.Message);
+ }
+ }
+
/// Convenience synchronous capture and analysis of a group of parts.
public static PlateOverlapReport Analyze(IReadOnlyList parts,
CancellationToken cancellationToken = default) =>
@@ -85,31 +95,33 @@ public static class PlateOverlapAnalyzer
/// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear.
///
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot,
+ CancellationToken cancellationToken = default) =>
+ Analyze(snapshot, null, cancellationToken);
+
+ ///
+ /// Incremental recheck: identical to a full analysis of , but a
+ /// pair whose two parts are unchanged since (same captured source
+ /// and bit-identical pose, in the same relative order) reuses that report's result instead
+ /// of being clipped again. After moving one part only its own neighbors are recomputed.
+ /// Reuse needs sources shared through one ; otherwise every
+ /// pair is recomputed. Null performs a full analysis.
+ ///
+ public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot, PlateOverlapReport previous,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(snapshot);
cancellationToken.ThrowIfCancellationRequested();
+ var reuse = PairReuse.Create(previous, snapshot);
var issues = snapshot.Issues.ToList();
var pairs = new List();
var prepared = new List();
- var sources = new Dictionary, PreparedSource>(ReferenceEqualityComparer.Instance);
foreach (var part in snapshot.Parts)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
- if (!sources.TryGetValue(part.Entities, out var source))
- {
- try
- {
- source = new PreparedSource(OverlapMaterial.Read(part.Entities, cancellationToken), null);
- }
- catch (Exception exception) when (IsGeometryFailure(exception))
- {
- source = new PreparedSource(null, exception.Message);
- }
- sources.Add(part.Entities, source);
- }
+ // Prepared material is shared by every part and request using this source.
+ var source = part.Source.Prepare(cancellationToken);
if (source.Error != null)
throw new ArgumentException(source.Error);
var material = source.Material.Transform(part.Rotation, part.Location);
@@ -139,6 +151,8 @@ public static class PlateOverlapAnalyzer
continue;
var a = first.Input.Id < second.Input.Id ? first : second;
var b = first.Input.Id < second.Input.Id ? second : first;
+ if (reuse != null && reuse.TryReuse(a.Input, b.Input, pairs, issues))
+ continue;
try
{
// Keep pair clipping arithmetic near the parts where possible, then
@@ -180,10 +194,77 @@ public static class PlateOverlapAnalyzer
cancellationToken.ThrowIfCancellationRequested();
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
- .ThenBy(issue => issue.PartBId).ToList());
+ .ThenBy(issue => issue.PartBId).ToList(), snapshot);
}
- private static bool IsGeometryFailure(Exception exception) => exception is
+ ///
+ /// Maps unchanged parts to their previous input positions and looks up previous pair results.
+ /// A part is unchanged when its captured source object and exact pose bits match; identical
+ /// duplicates are matched in input order, which is safe because their inputs are bit-identical.
+ ///
+ private sealed class PairReuse
+ {
+ private readonly Dictionary previousIds;
+ private readonly Dictionary<(int, int), PlateOverlapPair> pairs = new();
+ private readonly Dictionary<(int, int), PlateOverlapIssue> issues = new();
+
+ private PairReuse(Dictionary previousIds, PlateOverlapReport previous)
+ {
+ this.previousIds = previousIds;
+ foreach (var pair in previous.Pairs)
+ pairs[(pair.PartAId, pair.PartBId)] = pair;
+ foreach (var issue in previous.Issues)
+ if (issue.PartBId.HasValue)
+ issues[(issue.PartAId, issue.PartBId.Value)] = issue;
+ }
+
+ public static PairReuse Create(PlateOverlapReport previous, PlateOverlapSnapshot snapshot)
+ {
+ if (previous?.Snapshot == null)
+ return null;
+ var available = new Dictionary>();
+ foreach (var part in previous.Snapshot.Parts)
+ {
+ var key = PoseKey.Of(part);
+ if (!available.TryGetValue(key, out var ids))
+ available.Add(key, ids = new Queue());
+ ids.Enqueue(part.Id);
+ }
+ var previousIds = new Dictionary();
+ foreach (var part in snapshot.Parts)
+ if (available.TryGetValue(PoseKey.Of(part), out var ids) && ids.Count > 0)
+ previousIds.Add(part.Id, ids.Dequeue());
+ return previousIds.Count < 2 ? null : new PairReuse(previousIds, previous);
+ }
+
+ ///
+ /// Every bounding-box candidate pair among prepared parts was evaluated by the previous
+ /// analysis, and unchanged parts have identical bounds, so absence there means clear.
+ /// The previous pair must have had the same operand order: clipping is order-sensitive.
+ ///
+ public bool TryReuse(CapturedOverlapPart a, CapturedOverlapPart b,
+ List pairOutput, List issueOutput)
+ {
+ if (!previousIds.TryGetValue(a.Id, out var oldA) || !previousIds.TryGetValue(b.Id, out var oldB)
+ || oldA >= oldB)
+ return false;
+ if (pairs.TryGetValue((oldA, oldB), out var pair))
+ pairOutput.Add(pair.Renumber(a.Id, b.Id, a.Name, b.Name));
+ else if (issues.TryGetValue((oldA, oldB), out var issue))
+ issueOutput.Add(issue with { PartAId = a.Id, PartBId = b.Id });
+ return true;
+ }
+ }
+
+ private readonly record struct PoseKey(OverlapSource Source, long Rotation, long X, long Y)
+ {
+ public static PoseKey Of(CapturedOverlapPart part) => new(part.Source,
+ BitConverter.DoubleToInt64Bits(part.Rotation),
+ BitConverter.DoubleToInt64Bits(part.Location.X),
+ BitConverter.DoubleToInt64Bits(part.Location.Y));
+ }
+
+ internal static bool IsGeometryFailure(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
private static void ValidateProgram(Program program, HashSet visiting)
@@ -207,7 +288,5 @@ public static class PlateOverlapAnalyzer
visiting.Remove(program);
}
- private sealed record CapturedSource(List Entities, string Error);
- private sealed record PreparedSource(OverlapMaterial Material, string Error);
private sealed record PreparedPart(CapturedOverlapPart Input, OverlapMaterial Material);
}
diff --git a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs
index 8be7228..900483d 100644
--- a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs
+++ b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs
@@ -7,12 +7,17 @@ namespace OpenNest.Diagnostics;
/// An owned diagnostic result. An empty Pairs list is clear only if IsComplete is true.
public sealed class PlateOverlapReport
{
- internal PlateOverlapReport(List pairs, List issues)
+ internal PlateOverlapReport(List pairs, List issues,
+ PlateOverlapSnapshot snapshot)
{
Pairs = pairs.AsReadOnly();
Issues = issues.AsReadOnly();
+ Snapshot = snapshot;
}
+ /// The owned input this report was computed from; the baseline for incremental rechecks.
+ internal PlateOverlapSnapshot Snapshot { get; }
+
public IReadOnlyList Pairs { get; }
public IReadOnlyList Issues { get; }
public bool IsComplete => Issues.Count == 0;
@@ -24,6 +29,23 @@ public sealed class PlateOverlapPair
{
private readonly Box bounds;
+ private PlateOverlapPair(PlateOverlapPair source, int partAId, int partBId, string partAName,
+ string partBName)
+ {
+ PartAId = partAId;
+ PartBId = partBId;
+ PartAName = partAName;
+ PartBName = partBName;
+ Regions = source.Regions;
+ Area = source.Area;
+ Centroid = source.Centroid;
+ bounds = source.bounds;
+ }
+
+ /// The same immutable geometry under new input positions and captured names.
+ internal PlateOverlapPair Renumber(int partAId, int partBId, string partAName, string partBName) =>
+ new(this, partAId, partBId, partAName, partBName);
+
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
List regions, Vector centroid)
{
@@ -90,5 +112,5 @@ public sealed class PlateOverlapSnapshot
internal IReadOnlyList Issues { get; }
}
-internal sealed record CapturedOverlapPart(int Id, string Name, List Entities,
+internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
double Rotation, Vector Location);
diff --git a/OpenNest.Tests/Diagnostics/IncrementalOverlapAnalysisTests.cs b/OpenNest.Tests/Diagnostics/IncrementalOverlapAnalysisTests.cs
new file mode 100644
index 0000000..0f29522
--- /dev/null
+++ b/OpenNest.Tests/Diagnostics/IncrementalOverlapAnalysisTests.cs
@@ -0,0 +1,167 @@
+using OpenNest.CNC;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Diagnostics;
+
+public class IncrementalOverlapAnalysisTests
+{
+ [Theory]
+ [InlineData("move-onto")]
+ [InlineData("move-away")]
+ [InlineData("nudge-within")]
+ [InlineData("rotate")]
+ [InlineData("delete-first")]
+ [InlineData("insert-first")]
+ [InlineData("swap")]
+ [InlineData("rename")]
+ [InlineData("duplicate")]
+ [InlineData("nothing")]
+ public void IncrementalRecheckMatchesFullAnalysisExactly(string edit)
+ {
+ var drawing = Square("sq", 4);
+ var holed = Holed("holed");
+ var parts = new List
+ {
+ new(drawing, new Vector(0, 0)),
+ new(drawing, new Vector(3, 0)), // overlaps part 0
+ new(holed, new Vector(20, 0)),
+ new(drawing, new Vector(22, 2)), // sits inside the hole: no overlap
+ new(drawing, new Vector(40, 0)),
+ new(drawing, new Vector(42, 1)), // overlaps part 4
+ };
+ var cache = new OverlapMaterialCache();
+ var previous = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache));
+ Assert.Equal(2, previous.Pairs.Count);
+
+ switch (edit)
+ {
+ case "move-onto": parts[3].Offset(-1.5, 0); break; // now hits hole material
+ case "move-away": parts[1].Offset(10, 0); break; // clears pair 0/1
+ case "nudge-within": parts[5].Offset(0.25, 0.25); break; // same pair, new area
+ case "rotate": parts[1].Rotate(0.3, parts[1].Location); break;
+ case "delete-first": parts.RemoveAt(0); break; // renumbers every pair
+ case "insert-first": parts.Insert(0, new Part(drawing, new Vector(41, 0))); break;
+ case "swap": (parts[0], parts[1]) = (parts[1], parts[0]); break; // operand order flips
+ case "rename": parts[0].BaseDrawing.Name = "renamed"; break;
+ case "duplicate": parts.Add(new Part(drawing, new Vector(0, 0))); break; // coincident copy
+ }
+
+ var incremental = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache), previous);
+ var oracle = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts));
+ Assert.Equal(Describe(oracle), Describe(incremental));
+ }
+
+ [Fact]
+ public void UnchangedPairsAreReusedAndOnlyTheMovedPartsNeighborsAreRecomputed()
+ {
+ var drawing = Square("sq", 4);
+ var parts = new List { new(drawing, new Vector(0, 0)), new(drawing, new Vector(3, 0)),
+ new(drawing, new Vector(40, 0)), new(drawing, new Vector(43, 0)) };
+ var cache = new OverlapMaterialCache();
+ var previous = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache));
+ parts[3].Offset(0.5, 0);
+ var incremental = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache), previous);
+ // The untouched pair's immutable geometry is shared; the moved pair is new.
+ Assert.Same(previous.Pairs[0].Regions, incremental.Pairs[0].Regions);
+ Assert.NotSame(previous.Pairs[1].Regions, incremental.Pairs[1].Regions);
+ Assert.Equal(0.5 * 4, incremental.Pairs[1].Area, 9); // x 40..44 vs 43.5..47.5
+ }
+
+ [Fact]
+ public void PairIssuesAreReusedAndRenumberedLikePairs()
+ {
+ // Two copies of a drawing at a pose whose pair clipping fails are reported as a pair issue;
+ // an unrelated deletion before them must keep that issue, renumbered.
+ var drawing = Square("sq", 4);
+ var parts = new List { new(drawing, new Vector(100, 0)), new(drawing, new Vector(0, 0)),
+ new(drawing, new Vector(2, 0)) };
+ var cache = new OverlapMaterialCache();
+ var previous = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache));
+ var issue = new PlateOverlapIssue(1, 2, "synthetic pair failure");
+ var withIssue = WithIssue(previous, issue);
+ parts.RemoveAt(0);
+ var incremental = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache), withIssue);
+ Assert.Equal(new PlateOverlapIssue(0, 1, "synthetic pair failure"), Assert.Single(incremental.Issues));
+ }
+
+ [Fact]
+ public void BaselineFromAnotherCacheOrSnapshotIsNotReused()
+ {
+ var drawing = Square("sq", 4);
+ var parts = new[] { new Part(drawing, new Vector(0, 0)), new Part(drawing, new Vector(3, 0)) };
+ var previous = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, new OverlapMaterialCache()));
+ var incremental = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, new OverlapMaterialCache()), previous);
+ Assert.NotSame(previous.Pairs[0].Regions, incremental.Pairs[0].Regions);
+ Assert.Equal(Describe(previous), Describe(incremental));
+ }
+
+ [Fact]
+ public void CacheReusesPreparedMaterialUntilClearedOrTheProgramVisiblyChanges()
+ {
+ var drawing = Square("sq", 4);
+ var parts = new[] { new Part(drawing, new Vector(0, 0)), new Part(drawing, new Vector(3, 0)) };
+ var cache = new OverlapMaterialCache();
+ var first = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache));
+ Assert.Equal(4, first.Pairs[0].Area, 9);
+
+ // An in-place edit that keeps the code count is invisible to the cache: callers must Clear.
+ drawing.Program.Codes[2] = new LinearMove(4, 8);
+ drawing.Program.Codes[3] = new LinearMove(0, 8);
+ Assert.Equal(4, PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache)).Pairs[0].Area, 9);
+ cache.Clear();
+ Assert.Equal(8, PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache)).Pairs[0].Area, 9);
+
+ // A changed code count is detected without Clear (no stale reuse for obvious edits).
+ drawing.Program.Codes.Clear();
+ var broken = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache));
+ Assert.Empty(broken.Pairs);
+ Assert.False(broken.IsComplete);
+ }
+
+ [Fact]
+ public void CanceledPreparationIsNotCachedAsAFailure()
+ {
+ var drawing = Holed("holed");
+ var parts = new[] { new Part(drawing, new Vector(0, 0)), new Part(drawing, new Vector(1, 0)) };
+ var cache = new OverlapMaterialCache();
+ var snapshot = PlateOverlapAnalyzer.Capture(parts, cache);
+ using var canceled = new CancellationTokenSource();
+ canceled.Cancel();
+ Assert.ThrowsAny(() => PlateOverlapAnalyzer.Analyze(snapshot, canceled.Token));
+ var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache));
+ Assert.True(report.IsComplete);
+ Assert.Single(report.Pairs);
+ }
+
+ // Same baseline snapshot, but its only result is a pair issue (real pair failures need
+ // numeric edge cases); reuse must carry the issue rather than recompute a pair.
+ private static PlateOverlapReport WithIssue(PlateOverlapReport report, PlateOverlapIssue issue) =>
+ new(new List(), new List { issue }, report.Snapshot);
+
+ private static string Describe(PlateOverlapReport report) =>
+ string.Join(";", report.Pairs.Select(pair =>
+ $"{pair.PartAId}/{pair.PartBId}[{pair.PartAName}|{pair.PartBName}]:{pair.Area:R}:"
+ + $"{pair.Centroid.X:R},{pair.Centroid.Y:R}:"
+ + string.Join(",", pair.Regions.SelectMany(region => region.Vertices).Select(v => $"{v.X:R} {v.Y:R}"))))
+ + "|" + string.Join(";", report.Issues.Select(issue => $"{issue.PartAId}/{issue.PartBId}:{issue.Message}"));
+
+ private static Drawing Square(string name, double size) =>
+ new(name, Program(new[] { new Vector(0, 0), new Vector(size, 0), new Vector(size, size), new Vector(0, size) }));
+
+ private static Drawing Holed(string name) => new(name, Program(
+ new[] { new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10) },
+ new[] { new Vector(1, 1), new Vector(7, 1), new Vector(7, 7), new Vector(1, 7) }));
+
+ private static Program Program(params Vector[][] contours)
+ {
+ var program = new Program(Mode.Absolute);
+ foreach (var contour in contours)
+ {
+ program.Codes.Add(new RapidMove(contour[0]));
+ foreach (var point in contour.Skip(1).Append(contour[0]))
+ program.Codes.Add(new LinearMove(point));
+ }
+ return program;
+ }
+}