feat(posting): require verification and explicit risk acknowledgment

This commit is contained in:
aj
2026-09-28 22:50:03 -04:00
parent a5fddec0e9
commit 45d2dfced4
16 changed files with 1800 additions and 24 deletions
@@ -0,0 +1,69 @@
using System.Reflection;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.Diagnostics;
public class ConsolePostVerificationTests
{
[Fact]
public void RealConsoleBlocksOutputUntilExplicitRiskSwitchAndNeverRemembersConsent()
{
var directory = Path.Combine(Path.GetTempPath(), "opennest-post-verification-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
try
{
var input = Path.Combine(directory, "job.nest");
var output = Path.Combine(directory, "job.cnc");
var nest = new Nest("VerificationFixture");
var program = new OpenNest.CNC.Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(2, 0));
program.Codes.Add(new LinearMove(2, 2));
program.Codes.Add(new LinearMove(0, 2));
program.Codes.Add(new LinearMove(0, 0));
var drawing = new Drawing("square", program);
nest.Drawings.Add(drawing);
var plate = nest.CreatePlate();
plate.Size = new Size(10, 10);
plate.Parts.Add(new Part(drawing));
Assert.True(new NestWriter(nest).Write(input));
var args = new[] { input, "--quantity", "1", "--no-save", "--post", "Cincinnati CL-707",
"--posts-dir", AppContext.BaseDirectory, "--post-output", output };
Assert.Equal(1, Run(args));
Assert.False(File.Exists(output));
File.WriteAllText(output, "existing CNC must survive refusal");
Assert.Equal(1, Run(args));
Assert.Equal("existing CNC must survive refusal", File.ReadAllText(output));
var colliding = args.Where(arg => arg != "--no-save")
.Concat(new[] { "--output", Path.Combine(directory, ".", "job.cnc") }).ToArray();
Assert.Equal(1, Run(colliding));
Assert.Equal("existing CNC must survive refusal", File.ReadAllText(output));
Assert.Equal(1, Run(colliding.Append("--acknowledge-post-risks").ToArray()));
Assert.Equal("existing CNC must survive refusal", File.ReadAllText(output));
Assert.Equal(0, Run(args.Append("--acknowledge-post-risks").ToArray()));
var posted = File.ReadAllText(output);
Assert.Contains("M30", posted);
Assert.Contains("VerificationFixture", posted);
Assert.Equal(1, Run(args));
Assert.Equal(posted, File.ReadAllText(output));
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
private static int Run(string[] args)
{
var entry = Assembly.Load("OpenNest.Console").GetType("NestConsole")!
.GetMethod("Run", BindingFlags.Public | BindingFlags.Static)!;
return (int)entry.Invoke(null, new object[] { args })!;
}
}
@@ -0,0 +1,482 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Tests.Diagnostics;
public class PostVerificationAnalyzerTests
{
[Fact]
public void EmptyReport_ListsAllChecksAndLimits()
{
var report = PostVerificationAnalyzer.Analyze(new Nest());
Assert.Empty(report.Findings);
Assert.False(report.HasWarnings);
Assert.True(report.CanPost(false));
Assert.True(report.CanPost(true));
var text = report.ToDisplayText();
Assert.Contains("Overlap", text);
Assert.Contains("Missing lead-ins", text);
Assert.Contains("Rapid crossings", text);
Assert.Contains("not a physical safety certification", text);
Assert.Contains("retract", text);
Assert.Contains("routing", text);
}
[Fact]
public void Overlap_UsesCleanMaterialAndOriginalOneBasedIndices()
{
var a = Rectangle(0, 0, 4);
var b = Rectangle(1, 1, 1);
// Deliberately unrelated placed toolpaths: overlap must use clean drawings.
a.Program.Codes.Clear();
a.Program.MoveTo(100, 100);
a.Program.LineTo(101, 101);
var report = Analyze(Cutoff(30, 30), a, Scribe(40, 40), b);
var overlap = Assert.Single(Find(report, PostVerificationKind.Overlap));
Assert.Equal((1, 2, 4), (overlap.PlateNumber, overlap.PartNumber, overlap.OtherPartNumber));
Assert.True(report.HasWarnings);
Assert.False(report.CanPost(false));
Assert.True(report.CanPost(true));
Assert.False(report.CanPost(false)); // Consent is supplied per invocation, never latched.
}
[Fact]
public void Overlap_SubtractsHoles()
{
var program = Square(0, 0, 10);
program.Codes.AddRange(Square(2, 2, 6).Codes);
var frame = Part(program);
var report = Analyze(frame, Rectangle(3, 3, 1));
Assert.Empty(Find(report, PostVerificationKind.Overlap));
Assert.Empty(Find(report, PostVerificationKind.Incomplete));
}
[Fact]
public void MissingLeadIn_ManualFlagDoesNotProveCoverage()
{
var part = Rectangle(0, 0, 4);
part.HasManualLeadIns = true;
Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
}
[Fact]
public void MissingLeadIn_ActualLeadDoesNotRequireManualFlag()
{
var part = Rectangle(0, 0, 4);
part.ApplyLeadIns(Leads(), new Vector(-2, -2));
part.HasManualLeadIns = false;
Assert.Empty(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void MissingLeadIn_NoLeadOrZeroLengthWarns(bool zeroLength)
{
var part = Rectangle(0, 0, 4);
var parameters = Leads();
parameters.ExternalLeadIn = zeroLength ? new LineLeadIn { Length = 0 } : new NoLeadIn();
part.ApplyLeadIns(parameters, new Vector(-2, -2));
Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
}
[Fact]
public void MissingLeadIn_PartialApplySingleLeavesOtherContourUncovered()
{
var program = Square(0, 0, 10);
program.Codes.AddRange(Square(2, 2, 2).Codes);
var part = Part(program);
part.ApplySingleLeadIn(Leads(), Vector.Zero,
new Line(Vector.Zero, new Vector(10, 0)), ContourType.External);
Assert.True(part.HasManualLeadIns);
Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
}
[Fact]
public void ScribeAndCutoff_AreExemptFromOverlapAndLeadInChecks()
{
var report = Analyze(Scribe(0, 0), Cutoff(0, 0));
Assert.Empty(report.Findings);
}
[Fact]
public void Rapid_ThroughPreviouslyCutPartWarns()
{
var report = Analyze(Rectangle(0, 0, 4), Scribe(-2, 2), Scribe(6, 2));
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f =>
f.PartNumber == 3 && f.OtherPartNumber == 1);
}
[Fact]
public void Rapid_DoesNotUseFuturePartsAsObstacles()
{
var report = Analyze(Scribe(-2, 2), Scribe(6, 2), Rectangle(0, 0, 4));
Assert.Empty(Find(report, PostVerificationKind.RapidCrossing));
}
[Theory]
[InlineData(-2, -2, false)] // Start-only departure.
[InlineData(2, 2, true)] // Departure into the interior.
[InlineData(4, 0, true)] // Along edge / arrival on another endpoint.
public void Rapid_HandlesDepartureVersusInteriorAndBoundary(double x, double y, bool warning)
{
var report = Analyze(Rectangle(0, 0, 4), Scribe(x, y));
Assert.Equal(warning, Find(report, PostVerificationKind.RapidCrossing).Any());
}
[Theory]
[InlineData(-2, 4, 6, 4)] // Tangent along top edge.
[InlineData(-2, 2, 0, 2)] // Endpoint arrival.
[InlineData(1, 1, 2, 2)] // Entirely inside.
public void Rapid_ConservativelyFlagsContactAndInterior(double x1, double y1, double x2, double y2)
{
var report = Analyze(Rectangle(0, 0, 4), Scribe(x1, y1), Scribe(x2, y2));
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f => f.PartNumber == 3);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Rapid_ActualGapNotStaleTabSettingControlsObstacle(bool staleFlag)
{
var closed = Rectangle(0, 0, 4);
closed.CuttingParameters = new CuttingParameters { TabsEnabled = staleFlag };
Assert.Contains(Find(Analyze(closed, Scribe(-2, 2), Scribe(6, 2)),
PostVerificationKind.RapidCrossing), f => f.PartNumber == 3);
var gapped = Rectangle(0, 0, 4);
((LinearMove)gapped.Program.Codes[^1]).EndPoint = new Vector(0, 0.25);
gapped.CuttingParameters = new CuttingParameters { TabsEnabled = staleFlag };
Assert.Empty(Find(Analyze(gapped, Scribe(-2, 2), Scribe(6, 2)),
PostVerificationKind.RapidCrossing));
}
[Fact]
public void Rapid_RealGeneratedTabRemainsGapWithStaleDisabledParameters()
{
var part = Rectangle(0, 0, 4);
var parameters = Leads();
parameters.TabsEnabled = true;
parameters.TabConfig = new NormalTab { Size = 0.25 };
part.ApplyLeadIns(parameters, new Vector(-2, 0));
parameters.TabsEnabled = false;
Assert.Empty(Find(Analyze(part, Scribe(-2, 2), Scribe(6, 2)), PostVerificationKind.RapidCrossing));
}
[Fact]
public void Rapid_CutoffTravelStillCrossesEarlierRealCuts()
{
var report = Analyze(Rectangle(0, 0, 4), Scribe(-2, 2), Cutoff(6, 2));
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f =>
f.PartNumber == 3 && f.OtherPartNumber == 1);
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void Rapid_SamePartSharedHoleCallsUseOffsetsAndNonzeroLeadOrigins(Mode mode)
{
var part = Rectangle(100, 50, 20);
var hole = CircleWithLead();
hole.Mode = mode;
var placed = new Program();
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(5, 5), Id = 7 });
placed.MoveTo(2, 5);
placed.LineTo(2, 5); // Zero-length cut doesn't create an obstacle.
placed.MoveTo(8, 5); // Cross completed first hole.
placed.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(15, 5), Id = 7 });
placed.Mode = mode;
Assert.True(part.RestoreLeadInProgram(placed, false));
var report = Analyze(part);
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f =>
f.PartNumber == 1 && f.OtherPartNumber == 1);
Assert.Empty(Find(report, PostVerificationKind.Incomplete));
}
[Theory]
[InlineData(Mode.Absolute)]
[InlineData(Mode.Incremental)]
public void Rapid_NonzeroInitialLeadRapidIsNotAppliedTwice(Mode mode)
{
var part = Rectangle(100, 50, 4);
var placed = Square(0, 0, 4);
placed.Codes.Insert(0, new RapidMove(-2, 0));
placed.Codes[1] = new LinearMove(0, 0) { Layer = LayerType.Leadin };
placed.MoveTo(-2, 2);
placed.MoveTo(6, 2);
placed.Mode = mode;
part.RestoreLeadInProgram(placed, false);
var crossings = Find(Analyze(part), PostVerificationKind.RapidCrossing);
Assert.Single(crossings);
Assert.Empty(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
}
[Theory]
[InlineData(RotationType.CW)]
[InlineData(RotationType.CCW)]
public void Rapid_FullCircleArcTangentIsNotLostToChordApproximation(RotationType rotation)
{
var program = new Program();
program.MoveTo(1, 0);
program.ArcTo(1, 0, 0, 0, rotation);
var report = Analyze(Part(program), Scribe(-2, 1), Scribe(2, 1));
Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f => f.PartNumber == 3);
}
[Fact]
public void Rapid_SamePartFutureContourIsNotAnObstacle()
{
var part = Rectangle(0, 0, 10);
var placed = new Program();
placed.MoveTo(-2, 2);
placed.MoveTo(6, 2);
placed.Codes.AddRange(Square(0, 0, 4).Codes);
part.RestoreLeadInProgram(placed, false);
Assert.Empty(Find(Analyze(part), PostVerificationKind.RapidCrossing));
}
[Fact]
public void MultiplePlates_AreAllCheckedWithoutSharingObstacles()
{
var nest = NestWith(Rectangle(0, 0, 4));
var second = nest.CreatePlate();
second.Parts.Add(Scribe(-2, 2));
second.Parts.Add(Scribe(6, 2));
second.Parts.Add(Rectangle(0, 0, 4));
second.Parts.Add(Rectangle(1, 1, 1));
var report = PostVerificationAnalyzer.Analyze(nest);
Assert.Contains(Find(report, PostVerificationKind.MissingLeadIn), f => f.PlateNumber == 1);
Assert.Contains(Find(report, PostVerificationKind.MissingLeadIn), f => f.PlateNumber == 2);
Assert.All(Find(report, PostVerificationKind.Overlap), f => Assert.Equal(2, f.PlateNumber));
Assert.DoesNotContain(Find(report, PostVerificationKind.RapidCrossing), f =>
f.PlateNumber == 2 && f.PartNumber <= 3);
}
[Theory]
[InlineData("recursive")]
[InlineData("missing-call")]
[InlineData("null-code")]
[InlineData("nonfinite")]
[InlineData("bad-arc")]
[InlineData("empty")]
[InlineData("null-codes")]
public void MalformedPlacedPrograms_WarnInsteadOfClearing(string fault)
{
var part = Rectangle(0, 0, 4);
var program = part.Program;
switch (fault)
{
case "recursive": program.Codes.Add(new SubProgramCall { Program = program }); break;
case "missing-call": program.Codes.Add(new SubProgramCall()); break;
case "null-code": program.Codes.Add(null); break;
case "nonfinite": program.MoveTo(double.NaN, 0); break;
case "bad-arc": program.ArcTo(1, 1, 0, 0, RotationType.CW); break;
case "empty": program.Codes.Clear(); break;
case "null-codes": program.Codes = null; break;
}
var report = Analyze(part);
Assert.NotEmpty(Find(report, PostVerificationKind.Incomplete));
Assert.True(report.HasWarnings);
Assert.False(report.CanPost(false));
Assert.DoesNotContain("No warnings", report.ToDisplayText());
}
[Fact]
public void MalformedCleanProgram_OverlapIssuePreservesInputIndex()
{
var part = Rectangle(0, 0, 4);
part.BaseDrawing.Program.Codes.RemoveAt(4);
var report = Analyze(Cutoff(30, 30), part);
Assert.Contains(Find(report, PostVerificationKind.Incomplete), f => f.PartNumber == 2);
}
[Fact]
public void Rapid_ResumedContourBecomesObstacleOnlyAfterItsLastCut()
{
var part = Rectangle(0, 0, 4);
var placed = Square(0, 0, 4);
placed.Codes.Insert(3, new RapidMove(4, 4)); // Pause, with no uncut gap.
placed.MoveTo(-2, 2);
placed.MoveTo(6, 2);
part.RestoreLeadInProgram(placed, false);
Assert.Single(Find(Analyze(part), PostVerificationKind.RapidCrossing));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Rapid_InternalGapDoesNotBecomeAnObstacle(bool staleFlag)
{
var part = Rectangle(0, 0, 4);
var placed = Square(0, 0, 4);
placed.Codes.Insert(3, new RapidMove(3.75, 4)); // Leave a gap on the top edge.
placed.MoveTo(-2, 2);
placed.MoveTo(6, 2);
part.RestoreLeadInProgram(placed, false);
part.CuttingParameters = new CuttingParameters { TabsEnabled = staleFlag };
Assert.Empty(Find(Analyze(part), PostVerificationKind.RapidCrossing));
}
[Fact]
public void MissingPlacedCuts_CannotPassForACleanMaterialPart()
{
var part = Rectangle(0, 0, 4);
part.Program.Codes.Clear();
part.Program.MoveTo(0, 0);
var report = Analyze(part);
Assert.NotEmpty(Find(report, PostVerificationKind.Incomplete));
Assert.False(report.CanPost(false));
}
[Fact]
public void InvalidArcDirection_IsIncomplete()
{
var part = Part(CircleWithLead());
((ArcMove)part.Program.Codes[^1]).Rotation = (RotationType)99;
Assert.NotEmpty(Find(Analyze(part), PostVerificationKind.Incomplete));
}
[Fact]
public void Rapid_LeadOutThatCompletesTabGapIsNotTreatedAsRetained()
{
var part = Rectangle(0, 0, 4);
((LinearMove)part.Program.Codes[^1]).EndPoint = new Vector(0, 0.25);
part.Program.Codes.Add(new LinearMove(0, 0) { Layer = LayerType.Leadout });
var report = Analyze(part, Scribe(-2, 2), Scribe(6, 2));
Assert.True(Find(report, PostVerificationKind.RapidCrossing).Any()
|| Find(report, PostVerificationKind.Incomplete).Any());
}
[Fact]
public void Rapid_OutOfOrderFragmentsCannotSilentlyPassAsTabbed()
{
var part = Rectangle(0, 0, 4);
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(4, 0);
program.MoveTo(4, 4);
program.LineTo(0, 4);
program.MoveTo(4, 0);
program.LineTo(4, 4);
program.MoveTo(0, 4);
program.LineTo(0, 0);
part.RestoreLeadInProgram(program, false);
var report = Analyze(part, Scribe(-2, 2), Scribe(6, 2));
Assert.True(Find(report, PostVerificationKind.RapidCrossing).Any()
|| Find(report, PostVerificationKind.Incomplete).Any());
}
[Fact]
public void Cancellation_ThrowsWithoutChangingInputs()
{
var part = Rectangle(0, 0, 4);
var before = part.Program.ToString();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.Throws<OperationCanceledException>(() =>
PostVerificationAnalyzer.Analyze(NestWith(part), cancellation.Token));
Assert.Equal(before, part.Program.ToString());
}
[Fact]
public void Analyze_PreservesProgramsPosesSharedCallsAndCuttingState()
{
var part = Rectangle(100, 50, 20);
var sub = CircleWithLead();
var program = Square(0, 0, 20);
program.Codes.Insert(0, new SubProgramCall { Program = sub, Offset = new Vector(5, 5) });
program.Codes.Insert(1, new SubProgramCall { Program = sub, Offset = new Vector(15, 5) });
program.Mode = Mode.Incremental;
part.RestoreLeadInProgram(program, true);
part.CuttingParameters = Leads();
var nest = NestWith(part);
var codes = program.Codes.ToArray();
var before = program.ToString();
var beforeSub = sub.ToString();
var parameters = part.CuttingParameters;
var bounds = part.BoundingBox;
var quantity = part.BaseDrawing.Quantity.Nested;
var report = PostVerificationAnalyzer.Analyze(nest);
Assert.Same(program, part.Program);
Assert.Equal(codes, program.Codes);
Assert.Equal(before, program.ToString());
Assert.Equal(beforeSub, sub.ToString());
Assert.Same(sub, ((SubProgramCall)program[0]).Program);
Assert.Same(sub, ((SubProgramCall)program[1]).Program);
Assert.Equal(new Vector(100, 50), part.Location);
Assert.Same(bounds, part.BoundingBox);
Assert.True(part.HasManualLeadIns);
Assert.True(part.LeadInsLocked);
Assert.Same(parameters, part.CuttingParameters);
Assert.Equal(quantity, part.BaseDrawing.Quantity.Nested);
Assert.True(((ICollection<PostVerificationFinding>)report.Findings).IsReadOnly);
var display = report.ToDisplayText();
part.Program.Codes.Clear();
Assert.Equal(display, report.ToDisplayText());
}
private static PostVerificationFinding[] Find(PostVerificationReport report, PostVerificationKind kind) =>
report.Findings.Where(f => f.Kind == kind).ToArray();
private static PostVerificationReport Analyze(params Part[] parts) =>
PostVerificationAnalyzer.Analyze(NestWith(parts));
private static Nest NestWith(params Part[] parts)
{
var nest = new Nest();
var plate = nest.CreatePlate();
foreach (var part in parts)
plate.Parts.Add(part);
return nest;
}
private static Part Part(Program program) => new(new Drawing("fixture", program));
private static Part Rectangle(double x, double y, double size) =>
new(new Drawing("rectangle", Square(0, 0, size)), new Vector(x, y));
private static Program Square(double x, double y, double size)
{
var program = new Program();
program.MoveTo(x, y);
program.LineTo(x + size, y);
program.LineTo(x + size, y + size);
program.LineTo(x, y + size);
program.LineTo(x, y);
return program;
}
private static Part Scribe(double x, double y)
{
var program = new Program();
program.MoveTo(x, y);
program.Codes.Add(new LinearMove(x, y) { Layer = LayerType.Scribe });
return Part(program);
}
private static Part Cutoff(double x, double y)
{
var program = new Program();
program.MoveTo(x, y);
program.LineTo(x + 1, y);
return new Part(new Drawing("cutoff", program) { IsCutOff = true });
}
private static Program CircleWithLead()
{
var program = new Program();
program.MoveTo(0.5, 0);
program.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
program.Codes.Add(new ArcMove(1, 0, 0, 0) { Layer = LayerType.Display });
return program;
}
private static CuttingParameters Leads() => new()
{
ExternalLeadIn = new LineLeadIn { Length = 0.5 },
InternalLeadIn = new LineLeadIn { Length = 0.5 },
ArcCircleLeadIn = new LineLeadIn { Length = 0.5 }
};
}
@@ -0,0 +1,56 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
using OpenNest.Posts.Cincinnati;
using OpenNest.Posts.CincinnatiCIFiber;
using OpenNest.Posts.GravographIS;
namespace OpenNest.Tests.Diagnostics;
public class PostVerificationPostContractTests
{
[Fact]
public void ReorderingPostsRequireAcknowledgmentEvenWhenNestChecksAreClear()
{
var nest = ClearNest();
Assert.False(PostVerificationAnalyzer.Analyze(nest).HasWarnings);
IPostProcessor[] posts =
[
new CincinnatiPostProcessor(new CincinnatiPostConfig()),
new GravographISPostProcessor(new GravographISPostConfig())
];
foreach (var post in posts)
{
var report = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
Assert.Contains(report.Findings, finding => finding.Kind == PostVerificationKind.Incomplete);
Assert.False(report.CanPost(false));
Assert.True(report.CanPost(true));
Assert.Contains(post.Name, report.ToDisplayText());
}
}
[Fact]
public void CIFiberUsesPlacedOrderWithoutAnExtraOrderWarning()
{
var report = PostVerificationAnalyzer.AnalyzeForPost(ClearNest(),
new CIFiberPostProcessor(new CIFiberPostConfig()));
Assert.Empty(report.Findings);
Assert.True(report.CanPost(false));
}
private static Nest ClearNest()
{
var program = new OpenNest.CNC.Program();
program.MoveTo(0, 0);
program.LineTo(4, 0);
program.LineTo(4, 4);
program.LineTo(0, 4);
program.LineTo(0, 0);
var part = new Part(new Drawing("square", program));
part.ApplyLeadIns(new CuttingParameters { ExternalLeadIn = new LineLeadIn { Length = 0.5 } },
new Vector(-2, -2));
var nest = new Nest();
nest.CreatePlate().Parts.Add(part);
return nest;
}
}
+1
View File
@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
<ProjectReference Include="..\OpenNest.Console\OpenNest.Console.csproj" />
<ProjectReference Include="..\OpenNest.Benchmark\OpenNest.Benchmark.csproj" />
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />