From 57863e16e9af8d948858e94150ecb96f3e01934e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 17:27:25 -0400 Subject: [PATCH 01/20] feat(shapes): add ANSI pipe OD lookup table Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Core/Shapes/PipeSizes.cs | 69 +++++++++++++++++++++++++ OpenNest.Tests/Shapes/PipeSizesTests.cs | 54 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 OpenNest.Core/Shapes/PipeSizes.cs create mode 100644 OpenNest.Tests/Shapes/PipeSizesTests.cs diff --git a/OpenNest.Core/Shapes/PipeSizes.cs b/OpenNest.Core/Shapes/PipeSizes.cs new file mode 100644 index 0000000..d577a3a --- /dev/null +++ b/OpenNest.Core/Shapes/PipeSizes.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Shapes +{ + public static class PipeSizes + { + public readonly record struct Entry(string Label, double OuterDiameter); + + public static IReadOnlyList All { get; } = new List + { + new Entry("1/8", 0.405), + new Entry("1/4", 0.540), + new Entry("3/8", 0.675), + new Entry("1/2", 0.840), + new Entry("3/4", 1.050), + new Entry("1", 1.315), + new Entry("1 1/4", 1.660), + new Entry("1 1/2", 1.900), + new Entry("2", 2.375), + new Entry("2 1/2", 2.875), + new Entry("3", 3.500), + new Entry("3 1/2", 4.000), + new Entry("4", 4.500), + new Entry("4 1/2", 5.000), + new Entry("5", 5.563), + new Entry("6", 6.625), + new Entry("7", 7.625), + new Entry("8", 8.625), + new Entry("9", 9.625), + new Entry("10", 10.750), + new Entry("11", 11.750), + new Entry("12", 12.750), + new Entry("14", 14.000), + new Entry("16", 16.000), + new Entry("18", 18.000), + new Entry("20", 20.000), + new Entry("24", 24.000), + new Entry("26", 26.000), + new Entry("28", 28.000), + new Entry("30", 30.000), + new Entry("32", 32.000), + new Entry("34", 34.000), + new Entry("36", 36.000), + new Entry("42", 42.000), + new Entry("48", 48.000), + }; + + public static bool TryGetOD(string label, out double outerDiameter) + { + foreach (var entry in All) + { + if (entry.Label == label) + { + outerDiameter = entry.OuterDiameter; + return true; + } + } + + outerDiameter = 0; + return false; + } + + public static IEnumerable GetFittingSizes(double maxOD) + { + return All.Where(e => e.OuterDiameter <= maxOD); + } + } +} diff --git a/OpenNest.Tests/Shapes/PipeSizesTests.cs b/OpenNest.Tests/Shapes/PipeSizesTests.cs new file mode 100644 index 0000000..bb588cd --- /dev/null +++ b/OpenNest.Tests/Shapes/PipeSizesTests.cs @@ -0,0 +1,54 @@ +using OpenNest.Shapes; + +namespace OpenNest.Tests.Shapes; + +public class PipeSizesTests +{ + [Fact] + public void All_ContainsExpectedCount() + { + Assert.Equal(35, PipeSizes.All.Count); + } + + [Fact] + public void All_IsSortedByOuterDiameterAscending() + { + for (var i = 1; i < PipeSizes.All.Count; i++) + Assert.True(PipeSizes.All[i].OuterDiameter > PipeSizes.All[i - 1].OuterDiameter); + } + + [Theory] + [InlineData("1/8", 0.405)] + [InlineData("1/2", 0.840)] + [InlineData("2", 2.375)] + [InlineData("2 1/2", 2.875)] + [InlineData("12", 12.750)] + [InlineData("48", 48.000)] + public void TryGetOD_KnownLabel_ReturnsExpectedOD(string label, double expected) + { + Assert.True(PipeSizes.TryGetOD(label, out var od)); + Assert.Equal(expected, od, 0.001); + } + + [Fact] + public void TryGetOD_UnknownLabel_ReturnsFalse() + { + Assert.False(PipeSizes.TryGetOD("bogus", out _)); + } + + [Fact] + public void GetFittingSizes_FiltersByMaxOD() + { + var results = PipeSizes.GetFittingSizes(3.0).ToList(); + + Assert.Contains(results, e => e.Label == "2 1/2"); + Assert.DoesNotContain(results, e => e.Label == "3"); + Assert.DoesNotContain(results, e => e.Label == "4"); + } + + [Fact] + public void GetFittingSizes_MaxSmallerThanSmallest_ReturnsEmpty() + { + Assert.Empty(PipeSizes.GetFittingSizes(0.1)); + } +} From d215d02844d23ab55fd5b8026c38a8563d60065d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 17:31:22 -0400 Subject: [PATCH 02/20] style(shapes): remove redundant usings and document PipeSizes bound --- OpenNest.Core/Shapes/PipeSizes.cs | 15 ++++++++++++--- OpenNest.Tests/Shapes/PipeSizesTests.cs | 10 ++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/OpenNest.Core/Shapes/PipeSizes.cs b/OpenNest.Core/Shapes/PipeSizes.cs index d577a3a..b8ac4c5 100644 --- a/OpenNest.Core/Shapes/PipeSizes.cs +++ b/OpenNest.Core/Shapes/PipeSizes.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; namespace OpenNest.Shapes { @@ -7,7 +6,7 @@ namespace OpenNest.Shapes { public readonly record struct Entry(string Label, double OuterDiameter); - public static IReadOnlyList All { get; } = new List + public static IReadOnlyList All { get; } = new[] { new Entry("1/8", 0.405), new Entry("1/4", 0.540), @@ -61,9 +60,19 @@ namespace OpenNest.Shapes return false; } + /// + /// Returns all pipe sizes whose outer diameter is less than or equal to . + /// The bound is inclusive. + /// public static IEnumerable GetFittingSizes(double maxOD) { - return All.Where(e => e.OuterDiameter <= maxOD); + foreach (var entry in All) + { + if (entry.OuterDiameter <= maxOD) + { + yield return entry; + } + } } } } diff --git a/OpenNest.Tests/Shapes/PipeSizesTests.cs b/OpenNest.Tests/Shapes/PipeSizesTests.cs index bb588cd..eb12cfc 100644 --- a/OpenNest.Tests/Shapes/PipeSizesTests.cs +++ b/OpenNest.Tests/Shapes/PipeSizesTests.cs @@ -46,6 +46,16 @@ public class PipeSizesTests Assert.DoesNotContain(results, e => e.Label == "4"); } + [Fact] + public void GetFittingSizes_ExactBoundary_IsInclusive() + { + // NPS 3 has OD 3.500; passing maxOD = 3.500 should include it. + var results = PipeSizes.GetFittingSizes(3.500).ToList(); + + Assert.Contains(results, e => e.Label == "3"); + Assert.DoesNotContain(results, e => e.Label == "3 1/2"); + } + [Fact] public void GetFittingSizes_MaxSmallerThanSmallest_ReturnsEmpty() { From 6adc5b096737ab23b75edbbc12fb45c0c08225dc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 17:33:28 -0400 Subject: [PATCH 03/20] refactor(shapes): rename FlangeShape to PipeFlangeShape --- .../{FlangeShape.cs => PipeFlangeShape.cs} | 4 +- OpenNest.Tests/Shapes/FlangeShapeTests.cs | 104 ------------------ OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs | 54 +++++++++ 3 files changed, 55 insertions(+), 107 deletions(-) rename OpenNest.Core/Shapes/{FlangeShape.cs => PipeFlangeShape.cs} (90%) delete mode 100644 OpenNest.Tests/Shapes/FlangeShapeTests.cs create mode 100644 OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs diff --git a/OpenNest.Core/Shapes/FlangeShape.cs b/OpenNest.Core/Shapes/PipeFlangeShape.cs similarity index 90% rename from OpenNest.Core/Shapes/FlangeShape.cs rename to OpenNest.Core/Shapes/PipeFlangeShape.cs index 2eff093..f4be59d 100644 --- a/OpenNest.Core/Shapes/FlangeShape.cs +++ b/OpenNest.Core/Shapes/PipeFlangeShape.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; namespace OpenNest.Shapes { - public class FlangeShape : ShapeDefinition + public class PipeFlangeShape : ShapeDefinition { public double NominalPipeSize { get; set; } public double OD { get; set; } @@ -24,10 +24,8 @@ namespace OpenNest.Shapes { var entities = new List(); - // Outer circle entities.Add(new Circle(0, 0, OD / 2.0)); - // Bolt holes evenly spaced on the bolt circle var boltCircleRadius = HolePatternDiameter / 2.0; var holeRadius = HoleDiameter / 2.0; var angleStep = 2.0 * System.Math.PI / HoleCount; diff --git a/OpenNest.Tests/Shapes/FlangeShapeTests.cs b/OpenNest.Tests/Shapes/FlangeShapeTests.cs deleted file mode 100644 index 931582d..0000000 --- a/OpenNest.Tests/Shapes/FlangeShapeTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using OpenNest.Shapes; - -namespace OpenNest.Tests.Shapes; - -public class FlangeShapeTests -{ - [Fact] - public void GetDrawing_BoundingBoxMatchesOD() - { - var shape = new FlangeShape - { - OD = 10, - HoleDiameter = 1, - HolePatternDiameter = 7, - HoleCount = 4 - }; - var drawing = shape.GetDrawing(); - - var bbox = drawing.Program.BoundingBox(); - Assert.Equal(10, bbox.Width, 0.01); - Assert.Equal(10, bbox.Length, 0.01); - } - - [Fact] - public void GetDrawing_AreaExcludesBoltHoles() - { - var shape = new FlangeShape - { - OD = 10, - HoleDiameter = 1, - HolePatternDiameter = 7, - HoleCount = 4 - }; - var drawing = shape.GetDrawing(); - - // Area = pi * 5^2 - 4 * pi * 0.5^2 = pi * (25 - 1) = pi * 24 - var expectedArea = System.Math.PI * 24; - Assert.Equal(expectedArea, drawing.Area, 0.5); - } - - [Fact] - public void GetDrawing_DefaultName_IsFlange() - { - var shape = new FlangeShape - { - OD = 10, - HoleDiameter = 1, - HolePatternDiameter = 7, - HoleCount = 4 - }; - var drawing = shape.GetDrawing(); - - Assert.Equal("Flange", drawing.Name); - } - - [Fact] - public void LoadFromJson_ProducesCorrectDrawing() - { - var json = """ - [ - { - "Name": "2in-150#", - "NominalPipeSize": 2.0, - "OD": 6.0, - "HoleDiameter": 0.75, - "HolePatternDiameter": 4.75, - "HoleCount": 4 - }, - { - "Name": "2in-300#", - "NominalPipeSize": 2.0, - "OD": 6.5, - "HoleDiameter": 0.75, - "HolePatternDiameter": 5.0, - "HoleCount": 8 - } - ] - """; - - var tempFile = Path.GetTempFileName(); - try - { - File.WriteAllText(tempFile, json); - - var flanges = ShapeDefinition.LoadFromJson(tempFile); - - Assert.Equal(2, flanges.Count); - - var first = flanges[0]; - Assert.Equal("2in-150#", first.Name); - var drawing = first.GetDrawing(); - var bbox = drawing.Program.BoundingBox(); - Assert.Equal(6, bbox.Width, 0.01); - - var second = flanges[1]; - Assert.Equal("2in-300#", second.Name); - Assert.Equal(8, second.HoleCount); - } - finally - { - File.Delete(tempFile); - } - } -} diff --git a/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs b/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs new file mode 100644 index 0000000..3ba47e5 --- /dev/null +++ b/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs @@ -0,0 +1,54 @@ +using OpenNest.Shapes; + +namespace OpenNest.Tests.Shapes; + +public class PipeFlangeShapeTests +{ + [Fact] + public void GetDrawing_BoundingBoxMatchesOD() + { + var shape = new PipeFlangeShape + { + OD = 10, + HoleDiameter = 1, + HolePatternDiameter = 7, + HoleCount = 4 + }; + var drawing = shape.GetDrawing(); + + var bbox = drawing.Program.BoundingBox(); + Assert.Equal(10, bbox.Width, 0.01); + Assert.Equal(10, bbox.Length, 0.01); + } + + [Fact] + public void GetDrawing_AreaExcludesBoltHoles() + { + var shape = new PipeFlangeShape + { + OD = 10, + HoleDiameter = 1, + HolePatternDiameter = 7, + HoleCount = 4 + }; + var drawing = shape.GetDrawing(); + + var expectedArea = System.Math.PI * 24; + Assert.Equal(expectedArea, drawing.Area, 0.5); + } + + [Fact] + public void GetDrawing_DefaultName_IsPipeFlange() + { + var shape = new PipeFlangeShape + { + OD = 10, + HoleDiameter = 1, + HolePatternDiameter = 7, + HoleCount = 4 + }; + var drawing = shape.GetDrawing(); + + Assert.Equal("PipeFlange", drawing.Name); + } +} From 92a57d33dfbe74c93b0378f12d645b362f2cb80a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 17:36:10 -0400 Subject: [PATCH 04/20] feat(shapes): add pipe bore, clearance, and blind flag to PipeFlangeShape Replaces NominalPipeSize (double) with PipeSize (string), PipeClearance (double), and Blind (bool). GetDrawing cuts a center bore at pipeOD + PipeClearance unless Blind is true or PipeSize is unknown/null. Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Core/Shapes/PipeFlangeShape.cs | 14 +++- OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs | 82 ++++++++++++++++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/OpenNest.Core/Shapes/PipeFlangeShape.cs b/OpenNest.Core/Shapes/PipeFlangeShape.cs index f4be59d..7c5de3d 100644 --- a/OpenNest.Core/Shapes/PipeFlangeShape.cs +++ b/OpenNest.Core/Shapes/PipeFlangeShape.cs @@ -5,19 +5,23 @@ namespace OpenNest.Shapes { public class PipeFlangeShape : ShapeDefinition { - public double NominalPipeSize { get; set; } public double OD { get; set; } public double HoleDiameter { get; set; } public double HolePatternDiameter { get; set; } public int HoleCount { get; set; } + public string PipeSize { get; set; } + public double PipeClearance { get; set; } + public bool Blind { get; set; } public override void SetPreviewDefaults() { - NominalPipeSize = 2; OD = 7.5; HoleDiameter = 0.875; HolePatternDiameter = 5.5; HoleCount = 8; + PipeSize = "2"; + PipeClearance = 0.0625; + Blind = false; } public override Drawing GetDrawing() @@ -38,6 +42,12 @@ namespace OpenNest.Shapes entities.Add(new Circle(cx, cy, holeRadius)); } + if (!Blind && !string.IsNullOrEmpty(PipeSize) && PipeSizes.TryGetOD(PipeSize, out var pipeOD)) + { + var boreDiameter = pipeOD + PipeClearance; + entities.Add(new Circle(0, 0, boreDiameter / 2.0)); + } + return CreateDrawing(entities); } } diff --git a/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs b/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs index 3ba47e5..f8aafdf 100644 --- a/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs +++ b/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs @@ -29,7 +29,8 @@ public class PipeFlangeShapeTests OD = 10, HoleDiameter = 1, HolePatternDiameter = 7, - HoleCount = 4 + HoleCount = 4, + Blind = true }; var drawing = shape.GetDrawing(); @@ -51,4 +52,83 @@ public class PipeFlangeShapeTests Assert.Equal("PipeFlange", drawing.Name); } + + [Fact] + public void GetDrawing_WithPipeSize_CutsCenterBoreAtPipeODPlusClearance() + { + var shape = new PipeFlangeShape + { + OD = 10, + HoleDiameter = 1, + HolePatternDiameter = 7, + HoleCount = 4, + PipeSize = "2", // OD = 2.375 + PipeClearance = 0.125, + Blind = false + }; + var drawing = shape.GetDrawing(); + + // Expected bore diameter = 2.375 + 0.125 = 2.5 + // Area = pi * (5^2 - 0.5^2 * 4 - 1.25^2) = pi * (25 - 1 - 1.5625) = pi * 22.4375 + var expectedArea = System.Math.PI * 22.4375; + Assert.Equal(expectedArea, drawing.Area, 0.5); + } + + [Fact] + public void GetDrawing_Blind_OmitsCenterBore() + { + var shape = new PipeFlangeShape + { + OD = 10, + HoleDiameter = 1, + HolePatternDiameter = 7, + HoleCount = 4, + PipeSize = "2", + PipeClearance = 0.125, + Blind = true + }; + var drawing = shape.GetDrawing(); + + // With Blind=true, area = outer - 4 bolt holes = pi * (25 - 1) = pi * 24 + var expectedArea = System.Math.PI * 24; + Assert.Equal(expectedArea, drawing.Area, 0.5); + } + + [Fact] + public void GetDrawing_UnknownPipeSize_OmitsCenterBore() + { + var shape = new PipeFlangeShape + { + OD = 10, + HoleDiameter = 1, + HolePatternDiameter = 7, + HoleCount = 4, + PipeSize = "not-a-real-pipe", + PipeClearance = 0.125, + Blind = false + }; + var drawing = shape.GetDrawing(); + + // Unknown pipe size → no bore, area matches blind case + var expectedArea = System.Math.PI * 24; + Assert.Equal(expectedArea, drawing.Area, 0.5); + } + + [Fact] + public void GetDrawing_NullOrEmptyPipeSize_OmitsCenterBore() + { + var shape = new PipeFlangeShape + { + OD = 10, + HoleDiameter = 1, + HolePatternDiameter = 7, + HoleCount = 4, + PipeSize = null, + PipeClearance = 0.125 + }; + var drawing = shape.GetDrawing(); + + var expectedArea = System.Math.PI * 24; + Assert.Equal(expectedArea, drawing.Area, 0.5); + } } From 06485053fcee523198c17f50cad549ba4d180f34 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 17:39:50 -0400 Subject: [PATCH 05/20] test(shapes): cover empty-string PipeSize in addition to null --- OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs b/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs index f8aafdf..fbf3b37 100644 --- a/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs +++ b/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs @@ -114,8 +114,10 @@ public class PipeFlangeShapeTests Assert.Equal(expectedArea, drawing.Area, 0.5); } - [Fact] - public void GetDrawing_NullOrEmptyPipeSize_OmitsCenterBore() + [Theory] + [InlineData(null)] + [InlineData("")] + public void GetDrawing_NullOrEmptyPipeSize_OmitsCenterBore(string pipeSize) { var shape = new PipeFlangeShape { @@ -123,7 +125,7 @@ public class PipeFlangeShapeTests HoleDiameter = 1, HolePatternDiameter = 7, HoleCount = 4, - PipeSize = null, + PipeSize = pipeSize, PipeClearance = 0.125 }; var drawing = shape.GetDrawing(); From 4e7b5304a06df6594e07bb62c7fcd8647f097081 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 17:42:16 -0400 Subject: [PATCH 06/20] chore(shapes): migrate flange config to PipeFlangeShape schema Replace NominalPipeSize (double) with PipeSize (string label) and add PipeClearance: 0.0625 to all 136 entries in PipeFlangeShape.json. Co-Authored-By: Claude Sonnet 4.6 --- ...{FlangeShape.json => PipeFlangeShape.json} | 596 +++++++++++------- 1 file changed, 366 insertions(+), 230 deletions(-) rename OpenNest/Configurations/{FlangeShape.json => PipeFlangeShape.json} (63%) diff --git a/OpenNest/Configurations/FlangeShape.json b/OpenNest/Configurations/PipeFlangeShape.json similarity index 63% rename from OpenNest/Configurations/FlangeShape.json rename to OpenNest/Configurations/PipeFlangeShape.json index 07608d2..40a3722 100644 --- a/OpenNest/Configurations/FlangeShape.json +++ b/OpenNest/Configurations/PipeFlangeShape.json @@ -1,7 +1,8 @@ [ { "Name": "0.25in-150#", - "NominalPipeSize": 0.25, + "PipeSize": "1/4", + "PipeClearance": 0.0625, "OD": 3.375, "HoleDiameter": 0.62, "HolePatternDiameter": 2.25, @@ -9,7 +10,8 @@ }, { "Name": "0.25in-300#", - "NominalPipeSize": 0.25, + "PipeSize": "1/4", + "PipeClearance": 0.0625, "OD": 3.375, "HoleDiameter": 0.62, "HolePatternDiameter": 2.25, @@ -17,7 +19,8 @@ }, { "Name": "0.25in-400#", - "NominalPipeSize": 0.25, + "PipeSize": "1/4", + "PipeClearance": 0.0625, "OD": 3.375, "HoleDiameter": 0.62, "HolePatternDiameter": 2.25, @@ -25,7 +28,8 @@ }, { "Name": "0.25in-600#", - "NominalPipeSize": 0.25, + "PipeSize": "1/4", + "PipeClearance": 0.0625, "OD": 3.375, "HoleDiameter": 0.62, "HolePatternDiameter": 2.25, @@ -33,7 +37,8 @@ }, { "Name": "0.5in-150#", - "NominalPipeSize": 0.5, + "PipeSize": "1/2", + "PipeClearance": 0.0625, "OD": 3.5, "HoleDiameter": 0.62, "HolePatternDiameter": 2.38, @@ -41,7 +46,8 @@ }, { "Name": "0.5in-300#", - "NominalPipeSize": 0.5, + "PipeSize": "1/2", + "PipeClearance": 0.0625, "OD": 3.75, "HoleDiameter": 0.62, "HolePatternDiameter": 2.63, @@ -49,7 +55,8 @@ }, { "Name": "0.5in-400#", - "NominalPipeSize": 0.5, + "PipeSize": "1/2", + "PipeClearance": 0.0625, "OD": 3.75, "HoleDiameter": 0.62, "HolePatternDiameter": 2.63, @@ -57,7 +64,8 @@ }, { "Name": "0.5in-600#", - "NominalPipeSize": 0.5, + "PipeSize": "1/2", + "PipeClearance": 0.0625, "OD": 3.75, "HoleDiameter": 0.62, "HolePatternDiameter": 2.63, @@ -65,7 +73,8 @@ }, { "Name": "0.5in-900#", - "NominalPipeSize": 0.5, + "PipeSize": "1/2", + "PipeClearance": 0.0625, "OD": 4.75, "HoleDiameter": 0.88, "HolePatternDiameter": 3.25, @@ -73,7 +82,8 @@ }, { "Name": "0.5in-1500#", - "NominalPipeSize": 0.5, + "PipeSize": "1/2", + "PipeClearance": 0.0625, "OD": 4.75, "HoleDiameter": 0.88, "HolePatternDiameter": 3.25, @@ -81,7 +91,8 @@ }, { "Name": "0.5in-2500#", - "NominalPipeSize": 0.5, + "PipeSize": "1/2", + "PipeClearance": 0.0625, "OD": 5.25, "HoleDiameter": 0.88, "HolePatternDiameter": 3.5, @@ -89,7 +100,8 @@ }, { "Name": "0.75in-150#", - "NominalPipeSize": 0.75, + "PipeSize": "3/4", + "PipeClearance": 0.0625, "OD": 3.875, "HoleDiameter": 0.62, "HolePatternDiameter": 2.75, @@ -97,7 +109,8 @@ }, { "Name": "0.75in-300#", - "NominalPipeSize": 0.75, + "PipeSize": "3/4", + "PipeClearance": 0.0625, "OD": 4.625, "HoleDiameter": 0.75, "HolePatternDiameter": 3.25, @@ -105,7 +118,8 @@ }, { "Name": "0.75in-400#", - "NominalPipeSize": 0.75, + "PipeSize": "3/4", + "PipeClearance": 0.0625, "OD": 4.625, "HoleDiameter": 0.75, "HolePatternDiameter": 3.25, @@ -113,7 +127,8 @@ }, { "Name": "0.75in-600#", - "NominalPipeSize": 0.75, + "PipeSize": "3/4", + "PipeClearance": 0.0625, "OD": 4.625, "HoleDiameter": 0.75, "HolePatternDiameter": 3.25, @@ -121,7 +136,8 @@ }, { "Name": "0.75in-900#", - "NominalPipeSize": 0.75, + "PipeSize": "3/4", + "PipeClearance": 0.0625, "OD": 5.125, "HoleDiameter": 0.88, "HolePatternDiameter": 3.5, @@ -129,7 +145,8 @@ }, { "Name": "0.75in-1500#", - "NominalPipeSize": 0.75, + "PipeSize": "3/4", + "PipeClearance": 0.0625, "OD": 5.125, "HoleDiameter": 0.88, "HolePatternDiameter": 3.5, @@ -137,7 +154,8 @@ }, { "Name": "0.75in-2500#", - "NominalPipeSize": 0.75, + "PipeSize": "3/4", + "PipeClearance": 0.0625, "OD": 5.5, "HoleDiameter": 0.88, "HolePatternDiameter": 3.75, @@ -145,7 +163,8 @@ }, { "Name": "1in-150#", - "NominalPipeSize": 1.0, + "PipeSize": "1", + "PipeClearance": 0.0625, "OD": 4.25, "HoleDiameter": 0.62, "HolePatternDiameter": 3.13, @@ -153,7 +172,8 @@ }, { "Name": "1in-300#", - "NominalPipeSize": 1.0, + "PipeSize": "1", + "PipeClearance": 0.0625, "OD": 4.875, "HoleDiameter": 0.75, "HolePatternDiameter": 3.5, @@ -161,7 +181,8 @@ }, { "Name": "1in-400#", - "NominalPipeSize": 1.0, + "PipeSize": "1", + "PipeClearance": 0.0625, "OD": 4.875, "HoleDiameter": 0.75, "HolePatternDiameter": 3.5, @@ -169,7 +190,8 @@ }, { "Name": "1in-600#", - "NominalPipeSize": 1.0, + "PipeSize": "1", + "PipeClearance": 0.0625, "OD": 4.875, "HoleDiameter": 0.75, "HolePatternDiameter": 3.5, @@ -177,31 +199,35 @@ }, { "Name": "1in-900#", - "NominalPipeSize": 1.0, + "PipeSize": "1", + "PipeClearance": 0.0625, "OD": 5.875, - "HoleDiameter": 1.0, - "HolePatternDiameter": 4.0, + "HoleDiameter": 1, + "HolePatternDiameter": 4, "HoleCount": 4 }, { "Name": "1in-1500#", - "NominalPipeSize": 1.0, + "PipeSize": "1", + "PipeClearance": 0.0625, "OD": 5.875, - "HoleDiameter": 1.0, - "HolePatternDiameter": 4.0, + "HoleDiameter": 1, + "HolePatternDiameter": 4, "HoleCount": 4 }, { "Name": "1in-2500#", - "NominalPipeSize": 1.0, + "PipeSize": "1", + "PipeClearance": 0.0625, "OD": 6.25, - "HoleDiameter": 1.0, + "HoleDiameter": 1, "HolePatternDiameter": 4.25, "HoleCount": 4 }, { "Name": "1.25in-150#", - "NominalPipeSize": 1.25, + "PipeSize": "1 1/4", + "PipeClearance": 0.0625, "OD": 4.625, "HoleDiameter": 0.62, "HolePatternDiameter": 3.5, @@ -209,7 +235,8 @@ }, { "Name": "1.25in-300#", - "NominalPipeSize": 1.25, + "PipeSize": "1 1/4", + "PipeClearance": 0.0625, "OD": 5.25, "HoleDiameter": 0.75, "HolePatternDiameter": 3.88, @@ -217,7 +244,8 @@ }, { "Name": "1.25in-400#", - "NominalPipeSize": 1.25, + "PipeSize": "1 1/4", + "PipeClearance": 0.0625, "OD": 5.25, "HoleDiameter": 0.75, "HolePatternDiameter": 3.88, @@ -225,7 +253,8 @@ }, { "Name": "1.25in-600#", - "NominalPipeSize": 1.25, + "PipeSize": "1 1/4", + "PipeClearance": 0.0625, "OD": 5.25, "HoleDiameter": 0.75, "HolePatternDiameter": 3.88, @@ -233,23 +262,26 @@ }, { "Name": "1.25in-900#", - "NominalPipeSize": 1.25, + "PipeSize": "1 1/4", + "PipeClearance": 0.0625, "OD": 6.25, - "HoleDiameter": 1.0, + "HoleDiameter": 1, "HolePatternDiameter": 4.38, "HoleCount": 4 }, { "Name": "1.25in-1500#", - "NominalPipeSize": 1.25, + "PipeSize": "1 1/4", + "PipeClearance": 0.0625, "OD": 6.25, - "HoleDiameter": 1.0, + "HoleDiameter": 1, "HolePatternDiameter": 4.38, "HoleCount": 4 }, { "Name": "1.25in-2500#", - "NominalPipeSize": 1.25, + "PipeSize": "1 1/4", + "PipeClearance": 0.0625, "OD": 7.25, "HoleDiameter": 1.12, "HolePatternDiameter": 5.13, @@ -257,15 +289,17 @@ }, { "Name": "1.5in-150#", - "NominalPipeSize": 1.5, - "OD": 5.0, + "PipeSize": "1 1/2", + "PipeClearance": 0.0625, + "OD": 5, "HoleDiameter": 0.62, "HolePatternDiameter": 3.88, "HoleCount": 4 }, { "Name": "1.5in-300#", - "NominalPipeSize": 1.5, + "PipeSize": "1 1/2", + "PipeClearance": 0.0625, "OD": 6.125, "HoleDiameter": 0.88, "HolePatternDiameter": 4.5, @@ -273,7 +307,8 @@ }, { "Name": "1.5in-400#", - "NominalPipeSize": 1.5, + "PipeSize": "1 1/2", + "PipeClearance": 0.0625, "OD": 6.125, "HoleDiameter": 0.88, "HolePatternDiameter": 4.5, @@ -281,7 +316,8 @@ }, { "Name": "1.5in-600#", - "NominalPipeSize": 1.5, + "PipeSize": "1 1/2", + "PipeClearance": 0.0625, "OD": 6.125, "HoleDiameter": 0.88, "HolePatternDiameter": 4.5, @@ -289,79 +325,89 @@ }, { "Name": "1.5in-900#", - "NominalPipeSize": 1.5, - "OD": 7.0, + "PipeSize": "1 1/2", + "PipeClearance": 0.0625, + "OD": 7, "HoleDiameter": 1.12, "HolePatternDiameter": 4.88, "HoleCount": 4 }, { "Name": "1.5in-1500#", - "NominalPipeSize": 1.5, - "OD": 7.0, + "PipeSize": "1 1/2", + "PipeClearance": 0.0625, + "OD": 7, "HoleDiameter": 1.12, "HolePatternDiameter": 4.88, "HoleCount": 4 }, { "Name": "1.5in-2500#", - "NominalPipeSize": 1.5, - "OD": 8.0, + "PipeSize": "1 1/2", + "PipeClearance": 0.0625, + "OD": 8, "HoleDiameter": 1.25, "HolePatternDiameter": 5.75, "HoleCount": 4 }, { "Name": "2in-150#", - "NominalPipeSize": 2.0, - "OD": 6.0, + "PipeSize": "2", + "PipeClearance": 0.0625, + "OD": 6, "HoleDiameter": 0.75, "HolePatternDiameter": 4.75, "HoleCount": 4 }, { "Name": "2in-300#", - "NominalPipeSize": 2.0, + "PipeSize": "2", + "PipeClearance": 0.0625, "OD": 6.5, "HoleDiameter": 0.75, - "HolePatternDiameter": 5.0, + "HolePatternDiameter": 5, "HoleCount": 8 }, { "Name": "2in-400#", - "NominalPipeSize": 2.0, + "PipeSize": "2", + "PipeClearance": 0.0625, "OD": 6.5, "HoleDiameter": 0.75, - "HolePatternDiameter": 5.0, + "HolePatternDiameter": 5, "HoleCount": 8 }, { "Name": "2in-600#", - "NominalPipeSize": 2.0, + "PipeSize": "2", + "PipeClearance": 0.0625, "OD": 6.5, "HoleDiameter": 0.75, - "HolePatternDiameter": 5.0, + "HolePatternDiameter": 5, "HoleCount": 8 }, { "Name": "2in-900#", - "NominalPipeSize": 2.0, + "PipeSize": "2", + "PipeClearance": 0.0625, "OD": 8.5, - "HoleDiameter": 1.0, + "HoleDiameter": 1, "HolePatternDiameter": 6.5, "HoleCount": 8 }, { "Name": "2in-1500#", - "NominalPipeSize": 2.0, + "PipeSize": "2", + "PipeClearance": 0.0625, "OD": 8.5, - "HoleDiameter": 1.0, + "HoleDiameter": 1, "HolePatternDiameter": 6.5, "HoleCount": 8 }, { "Name": "2in-2500#", - "NominalPipeSize": 2.0, + "PipeSize": "2", + "PipeClearance": 0.0625, "OD": 9.25, "HoleDiameter": 1.12, "HolePatternDiameter": 6.75, @@ -369,15 +415,17 @@ }, { "Name": "2.5in-150#", - "NominalPipeSize": 2.5, - "OD": 7.0, + "PipeSize": "2 1/2", + "PipeClearance": 0.0625, + "OD": 7, "HoleDiameter": 0.75, "HolePatternDiameter": 5.5, "HoleCount": 4 }, { "Name": "2.5in-300#", - "NominalPipeSize": 2.5, + "PipeSize": "2 1/2", + "PipeClearance": 0.0625, "OD": 7.5, "HoleDiameter": 0.88, "HolePatternDiameter": 5.88, @@ -385,7 +433,8 @@ }, { "Name": "2.5in-400#", - "NominalPipeSize": 2.5, + "PipeSize": "2 1/2", + "PipeClearance": 0.0625, "OD": 7.5, "HoleDiameter": 0.88, "HolePatternDiameter": 5.88, @@ -393,7 +442,8 @@ }, { "Name": "2.5in-600#", - "NominalPipeSize": 2.5, + "PipeSize": "2 1/2", + "PipeClearance": 0.0625, "OD": 7.5, "HoleDiameter": 0.88, "HolePatternDiameter": 5.88, @@ -401,7 +451,8 @@ }, { "Name": "2.5in-900#", - "NominalPipeSize": 2.5, + "PipeSize": "2 1/2", + "PipeClearance": 0.0625, "OD": 9.625, "HoleDiameter": 1.12, "HolePatternDiameter": 7.5, @@ -409,7 +460,8 @@ }, { "Name": "2.5in-1500#", - "NominalPipeSize": 2.5, + "PipeSize": "2 1/2", + "PipeClearance": 0.0625, "OD": 9.625, "HoleDiameter": 1.12, "HolePatternDiameter": 7.5, @@ -417,7 +469,8 @@ }, { "Name": "2.5in-2500#", - "NominalPipeSize": 2.5, + "PipeSize": "2 1/2", + "PipeClearance": 0.0625, "OD": 10.5, "HoleDiameter": 1.25, "HolePatternDiameter": 7.75, @@ -425,15 +478,17 @@ }, { "Name": "3in-150#", - "NominalPipeSize": 3.0, + "PipeSize": "3", + "PipeClearance": 0.0625, "OD": 7.5, "HoleDiameter": 0.75, - "HolePatternDiameter": 6.0, + "HolePatternDiameter": 6, "HoleCount": 4 }, { "Name": "3in-300#", - "NominalPipeSize": 3.0, + "PipeSize": "3", + "PipeClearance": 0.0625, "OD": 8.25, "HoleDiameter": 0.88, "HolePatternDiameter": 6.63, @@ -441,7 +496,8 @@ }, { "Name": "3in-400#", - "NominalPipeSize": 3.0, + "PipeSize": "3", + "PipeClearance": 0.0625, "OD": 8.25, "HoleDiameter": 0.88, "HolePatternDiameter": 6.63, @@ -449,7 +505,8 @@ }, { "Name": "3in-600#", - "NominalPipeSize": 3.0, + "PipeSize": "3", + "PipeClearance": 0.0625, "OD": 8.25, "HoleDiameter": 0.88, "HolePatternDiameter": 6.63, @@ -457,95 +514,107 @@ }, { "Name": "3in-900#", - "NominalPipeSize": 3.0, + "PipeSize": "3", + "PipeClearance": 0.0625, "OD": 9.5, - "HoleDiameter": 1.0, + "HoleDiameter": 1, "HolePatternDiameter": 7.5, "HoleCount": 8 }, { "Name": "3in-1500#", - "NominalPipeSize": 3.0, + "PipeSize": "3", + "PipeClearance": 0.0625, "OD": 10.5, "HoleDiameter": 1.25, - "HolePatternDiameter": 8.0, + "HolePatternDiameter": 8, "HoleCount": 8 }, { "Name": "3in-2500#", - "NominalPipeSize": 3.0, - "OD": 12.0, + "PipeSize": "3", + "PipeClearance": 0.0625, + "OD": 12, "HoleDiameter": 1.38, - "HolePatternDiameter": 9.0, + "HolePatternDiameter": 9, "HoleCount": 8 }, { "Name": "3.5in-150#", - "NominalPipeSize": 3.5, + "PipeSize": "3 1/2", + "PipeClearance": 0.0625, "OD": 8.5, "HoleDiameter": 0.75, - "HolePatternDiameter": 7.0, + "HolePatternDiameter": 7, "HoleCount": 8 }, { "Name": "3.5in-300#", - "NominalPipeSize": 3.5, - "OD": 9.0, + "PipeSize": "3 1/2", + "PipeClearance": 0.0625, + "OD": 9, "HoleDiameter": 0.88, "HolePatternDiameter": 7.25, "HoleCount": 8 }, { "Name": "3.5in-400#", - "NominalPipeSize": 3.5, - "OD": 9.0, - "HoleDiameter": 1.0, + "PipeSize": "3 1/2", + "PipeClearance": 0.0625, + "OD": 9, + "HoleDiameter": 1, "HolePatternDiameter": 7.25, "HoleCount": 8 }, { "Name": "3.5in-600#", - "NominalPipeSize": 3.5, - "OD": 9.0, - "HoleDiameter": 1.0, + "PipeSize": "3 1/2", + "PipeClearance": 0.0625, + "OD": 9, + "HoleDiameter": 1, "HolePatternDiameter": 7.25, "HoleCount": 8 }, { "Name": "4in-150#", - "NominalPipeSize": 4.0, - "OD": 9.0, + "PipeSize": "4", + "PipeClearance": 0.0625, + "OD": 9, "HoleDiameter": 0.75, "HolePatternDiameter": 7.5, "HoleCount": 8 }, { "Name": "4in-300#", - "NominalPipeSize": 4.0, - "OD": 10.0, + "PipeSize": "4", + "PipeClearance": 0.0625, + "OD": 10, "HoleDiameter": 0.88, "HolePatternDiameter": 7.88, "HoleCount": 8 }, { "Name": "4in-400#", - "NominalPipeSize": 4.0, - "OD": 10.0, - "HoleDiameter": 1.0, + "PipeSize": "4", + "PipeClearance": 0.0625, + "OD": 10, + "HoleDiameter": 1, "HolePatternDiameter": 7.88, "HoleCount": 8 }, { "Name": "4in-600#", - "NominalPipeSize": 4.0, + "PipeSize": "4", + "PipeClearance": 0.0625, "OD": 10.75, - "HoleDiameter": 1.0, + "HoleDiameter": 1, "HolePatternDiameter": 8.5, "HoleCount": 8 }, { "Name": "4in-900#", - "NominalPipeSize": 4.0, + "PipeSize": "4", + "PipeClearance": 0.0625, "OD": 11.5, "HoleDiameter": 1.25, "HolePatternDiameter": 9.25, @@ -553,7 +622,8 @@ }, { "Name": "4in-1500#", - "NominalPipeSize": 4.0, + "PipeSize": "4", + "PipeClearance": 0.0625, "OD": 12.25, "HoleDiameter": 1.38, "HolePatternDiameter": 9.5, @@ -561,55 +631,62 @@ }, { "Name": "4in-2500#", - "NominalPipeSize": 4.0, - "OD": 14.0, + "PipeSize": "4", + "PipeClearance": 0.0625, + "OD": 14, "HoleDiameter": 1.62, "HolePatternDiameter": 10.75, "HoleCount": 8 }, { "Name": "5in-150#", - "NominalPipeSize": 5.0, - "OD": 10.0, + "PipeSize": "5", + "PipeClearance": 0.0625, + "OD": 10, "HoleDiameter": 0.88, "HolePatternDiameter": 8.5, "HoleCount": 8 }, { "Name": "5in-300#", - "NominalPipeSize": 5.0, - "OD": 11.0, + "PipeSize": "5", + "PipeClearance": 0.0625, + "OD": 11, "HoleDiameter": 0.88, "HolePatternDiameter": 9.25, "HoleCount": 8 }, { "Name": "5in-400#", - "NominalPipeSize": 5.0, - "OD": 11.0, - "HoleDiameter": 1.0, + "PipeSize": "5", + "PipeClearance": 0.0625, + "OD": 11, + "HoleDiameter": 1, "HolePatternDiameter": 9.25, "HoleCount": 8 }, { "Name": "5in-600#", - "NominalPipeSize": 5.0, - "OD": 13.0, + "PipeSize": "5", + "PipeClearance": 0.0625, + "OD": 13, "HoleDiameter": 1.12, "HolePatternDiameter": 10.5, "HoleCount": 8 }, { "Name": "5in-900#", - "NominalPipeSize": 5.0, + "PipeSize": "5", + "PipeClearance": 0.0625, "OD": 13.75, "HoleDiameter": 1.38, - "HolePatternDiameter": 11.0, + "HolePatternDiameter": 11, "HoleCount": 8 }, { "Name": "5in-1500#", - "NominalPipeSize": 5.0, + "PipeSize": "5", + "PipeClearance": 0.0625, "OD": 14.75, "HoleDiameter": 1.62, "HolePatternDiameter": 11.5, @@ -617,7 +694,8 @@ }, { "Name": "5in-2500#", - "NominalPipeSize": 5.0, + "PipeSize": "5", + "PipeClearance": 0.0625, "OD": 16.5, "HoleDiameter": 1.88, "HolePatternDiameter": 12.75, @@ -625,15 +703,17 @@ }, { "Name": "6in-150#", - "NominalPipeSize": 6.0, - "OD": 11.0, + "PipeSize": "6", + "PipeClearance": 0.0625, + "OD": 11, "HoleDiameter": 0.88, "HolePatternDiameter": 9.5, "HoleCount": 8 }, { "Name": "6in-300#", - "NominalPipeSize": 6.0, + "PipeSize": "6", + "PipeClearance": 0.0625, "OD": 12.5, "HoleDiameter": 0.88, "HolePatternDiameter": 10.63, @@ -641,31 +721,35 @@ }, { "Name": "6in-400#", - "NominalPipeSize": 6.0, + "PipeSize": "6", + "PipeClearance": 0.0625, "OD": 12.5, - "HoleDiameter": 1.0, + "HoleDiameter": 1, "HolePatternDiameter": 10.63, "HoleCount": 12 }, { "Name": "6in-600#", - "NominalPipeSize": 6.0, - "OD": 14.0, + "PipeSize": "6", + "PipeClearance": 0.0625, + "OD": 14, "HoleDiameter": 1.12, "HolePatternDiameter": 11.5, "HoleCount": 12 }, { "Name": "6in-900#", - "NominalPipeSize": 6.0, - "OD": 15.0, + "PipeSize": "6", + "PipeClearance": 0.0625, + "OD": 15, "HoleDiameter": 1.25, "HolePatternDiameter": 12.5, "HoleCount": 12 }, { "Name": "6in-1500#", - "NominalPipeSize": 6.0, + "PipeSize": "6", + "PipeClearance": 0.0625, "OD": 15.5, "HoleDiameter": 1.5, "HolePatternDiameter": 12.5, @@ -673,15 +757,17 @@ }, { "Name": "6in-2500#", - "NominalPipeSize": 6.0, - "OD": 19.0, + "PipeSize": "6", + "PipeClearance": 0.0625, + "OD": 19, "HoleDiameter": 2.12, "HolePatternDiameter": 14.5, "HoleCount": 8 }, { "Name": "8in-150#", - "NominalPipeSize": 8.0, + "PipeSize": "8", + "PipeClearance": 0.0625, "OD": 13.5, "HoleDiameter": 0.88, "HolePatternDiameter": 11.75, @@ -689,23 +775,26 @@ }, { "Name": "8in-300#", - "NominalPipeSize": 8.0, - "OD": 15.0, - "HoleDiameter": 1.0, - "HolePatternDiameter": 13.0, + "PipeSize": "8", + "PipeClearance": 0.0625, + "OD": 15, + "HoleDiameter": 1, + "HolePatternDiameter": 13, "HoleCount": 12 }, { "Name": "8in-400#", - "NominalPipeSize": 8.0, - "OD": 15.0, + "PipeSize": "8", + "PipeClearance": 0.0625, + "OD": 15, "HoleDiameter": 1.12, - "HolePatternDiameter": 13.0, + "HolePatternDiameter": 13, "HoleCount": 12 }, { "Name": "8in-600#", - "NominalPipeSize": 8.0, + "PipeSize": "8", + "PipeClearance": 0.0625, "OD": 16.5, "HoleDiameter": 1.25, "HolePatternDiameter": 13.75, @@ -713,7 +802,8 @@ }, { "Name": "8in-900#", - "NominalPipeSize": 8.0, + "PipeSize": "8", + "PipeClearance": 0.0625, "OD": 18.5, "HoleDiameter": 1.5, "HolePatternDiameter": 15.5, @@ -721,15 +811,17 @@ }, { "Name": "8in-1500#", - "NominalPipeSize": 8.0, - "OD": 19.0, + "PipeSize": "8", + "PipeClearance": 0.0625, + "OD": 19, "HoleDiameter": 1.75, "HolePatternDiameter": 15.5, "HoleCount": 12 }, { "Name": "8in-2500#", - "NominalPipeSize": 8.0, + "PipeSize": "8", + "PipeClearance": 0.0625, "OD": 21.75, "HoleDiameter": 2.12, "HolePatternDiameter": 17.25, @@ -737,15 +829,17 @@ }, { "Name": "10in-150#", - "NominalPipeSize": 10.0, - "OD": 16.0, - "HoleDiameter": 1.0, + "PipeSize": "10", + "PipeClearance": 0.0625, + "OD": 16, + "HoleDiameter": 1, "HolePatternDiameter": 14.25, "HoleCount": 12 }, { "Name": "10in-300#", - "NominalPipeSize": 10.0, + "PipeSize": "10", + "PipeClearance": 0.0625, "OD": 17.5, "HoleDiameter": 1.12, "HolePatternDiameter": 15.25, @@ -753,7 +847,8 @@ }, { "Name": "10in-400#", - "NominalPipeSize": 10.0, + "PipeSize": "10", + "PipeClearance": 0.0625, "OD": 17.5, "HoleDiameter": 1.25, "HolePatternDiameter": 15.25, @@ -761,15 +856,17 @@ }, { "Name": "10in-600#", - "NominalPipeSize": 10.0, - "OD": 20.0, + "PipeSize": "10", + "PipeClearance": 0.0625, + "OD": 20, "HoleDiameter": 1.38, - "HolePatternDiameter": 17.0, + "HolePatternDiameter": 17, "HoleCount": 16 }, { "Name": "10in-900#", - "NominalPipeSize": 10.0, + "PipeSize": "10", + "PipeClearance": 0.0625, "OD": 21.5, "HoleDiameter": 1.5, "HolePatternDiameter": 18.5, @@ -777,15 +874,17 @@ }, { "Name": "10in-1500#", - "NominalPipeSize": 10.0, - "OD": 23.0, - "HoleDiameter": 2.0, - "HolePatternDiameter": 19.0, + "PipeSize": "10", + "PipeClearance": 0.0625, + "OD": 23, + "HoleDiameter": 2, + "HolePatternDiameter": 19, "HoleCount": 12 }, { "Name": "10in-2500#", - "NominalPipeSize": 10.0, + "PipeSize": "10", + "PipeClearance": 0.0625, "OD": 26.5, "HoleDiameter": 2.62, "HolePatternDiameter": 21.25, @@ -793,15 +892,17 @@ }, { "Name": "12in-150#", - "NominalPipeSize": 12.0, - "OD": 19.0, - "HoleDiameter": 1.0, - "HolePatternDiameter": 17.0, + "PipeSize": "12", + "PipeClearance": 0.0625, + "OD": 19, + "HoleDiameter": 1, + "HolePatternDiameter": 17, "HoleCount": 12 }, { "Name": "12in-300#", - "NominalPipeSize": 12.0, + "PipeSize": "12", + "PipeClearance": 0.0625, "OD": 20.5, "HoleDiameter": 1.25, "HolePatternDiameter": 17.75, @@ -809,7 +910,8 @@ }, { "Name": "12in-400#", - "NominalPipeSize": 12.0, + "PipeSize": "12", + "PipeClearance": 0.0625, "OD": 20.5, "HoleDiameter": 1.38, "HolePatternDiameter": 17.75, @@ -817,23 +919,26 @@ }, { "Name": "12in-600#", - "NominalPipeSize": 12.0, - "OD": 22.0, + "PipeSize": "12", + "PipeClearance": 0.0625, + "OD": 22, "HoleDiameter": 1.38, "HolePatternDiameter": 19.25, "HoleCount": 20 }, { "Name": "12in-900#", - "NominalPipeSize": 12.0, - "OD": 24.0, + "PipeSize": "12", + "PipeClearance": 0.0625, + "OD": 24, "HoleDiameter": 1.5, - "HolePatternDiameter": 21.0, + "HolePatternDiameter": 21, "HoleCount": 20 }, { "Name": "12in-1500#", - "NominalPipeSize": 12.0, + "PipeSize": "12", + "PipeClearance": 0.0625, "OD": 26.5, "HoleDiameter": 2.12, "HolePatternDiameter": 22.5, @@ -841,39 +946,44 @@ }, { "Name": "12in-2500#", - "NominalPipeSize": 12.0, - "OD": 30.0, + "PipeSize": "12", + "PipeClearance": 0.0625, + "OD": 30, "HoleDiameter": 2.88, "HolePatternDiameter": 24.375, "HoleCount": 12 }, { "Name": "14in-150#", - "NominalPipeSize": 14.0, - "OD": 21.0, + "PipeSize": "14", + "PipeClearance": 0.0625, + "OD": 21, "HoleDiameter": 1.12, "HolePatternDiameter": 18.75, "HoleCount": 12 }, { "Name": "14in-300#", - "NominalPipeSize": 14.0, - "OD": 23.0, + "PipeSize": "14", + "PipeClearance": 0.0625, + "OD": 23, "HoleDiameter": 1.25, "HolePatternDiameter": 20.25, "HoleCount": 20 }, { "Name": "14in-400#", - "NominalPipeSize": 14.0, - "OD": 23.0, + "PipeSize": "14", + "PipeClearance": 0.0625, + "OD": 23, "HoleDiameter": 1.38, "HolePatternDiameter": 20.25, "HoleCount": 20 }, { "Name": "14in-600#", - "NominalPipeSize": 14.0, + "PipeSize": "14", + "PipeClearance": 0.0625, "OD": 23.75, "HoleDiameter": 1.5, "HolePatternDiameter": 20.75, @@ -881,23 +991,26 @@ }, { "Name": "14in-900#", - "NominalPipeSize": 14.0, + "PipeSize": "14", + "PipeClearance": 0.0625, "OD": 25.25, "HoleDiameter": 1.62, - "HolePatternDiameter": 22.0, + "HolePatternDiameter": 22, "HoleCount": 20 }, { "Name": "14in-1500#", - "NominalPipeSize": 14.0, + "PipeSize": "14", + "PipeClearance": 0.0625, "OD": 29.5, "HoleDiameter": 2.38, - "HolePatternDiameter": 25.0, + "HolePatternDiameter": 25, "HoleCount": 16 }, { "Name": "16in-150#", - "NominalPipeSize": 16.0, + "PipeSize": "16", + "PipeClearance": 0.0625, "OD": 23.5, "HoleDiameter": 1.12, "HolePatternDiameter": 21.25, @@ -905,7 +1018,8 @@ }, { "Name": "16in-300#", - "NominalPipeSize": 16.0, + "PipeSize": "16", + "PipeClearance": 0.0625, "OD": 25.5, "HoleDiameter": 1.38, "HolePatternDiameter": 22.5, @@ -913,7 +1027,8 @@ }, { "Name": "16in-400#", - "NominalPipeSize": 16.0, + "PipeSize": "16", + "PipeClearance": 0.0625, "OD": 25.5, "HoleDiameter": 1.5, "HolePatternDiameter": 22.5, @@ -921,15 +1036,17 @@ }, { "Name": "16in-600#", - "NominalPipeSize": 16.0, - "OD": 27.0, + "PipeSize": "16", + "PipeClearance": 0.0625, + "OD": 27, "HoleDiameter": 1.62, "HolePatternDiameter": 23.75, "HoleCount": 20 }, { "Name": "16in-900#", - "NominalPipeSize": 16.0, + "PipeSize": "16", + "PipeClearance": 0.0625, "OD": 27.75, "HoleDiameter": 1.75, "HolePatternDiameter": 24.5, @@ -937,7 +1054,8 @@ }, { "Name": "16in-1500#", - "NominalPipeSize": 16.0, + "PipeSize": "16", + "PipeClearance": 0.0625, "OD": 32.5, "HoleDiameter": 2.62, "HolePatternDiameter": 27.75, @@ -945,31 +1063,35 @@ }, { "Name": "18in-150#", - "NominalPipeSize": 18.0, - "OD": 25.0, + "PipeSize": "18", + "PipeClearance": 0.0625, + "OD": 25, "HoleDiameter": 1.25, "HolePatternDiameter": 22.75, "HoleCount": 16 }, { "Name": "18in-300#", - "NominalPipeSize": 18.0, - "OD": 28.0, + "PipeSize": "18", + "PipeClearance": 0.0625, + "OD": 28, "HoleDiameter": 1.38, "HolePatternDiameter": 24.75, "HoleCount": 24 }, { "Name": "18in-400#", - "NominalPipeSize": 18.0, - "OD": 28.0, + "PipeSize": "18", + "PipeClearance": 0.0625, + "OD": 28, "HoleDiameter": 1.5, "HolePatternDiameter": 24.75, "HoleCount": 24 }, { "Name": "18in-600#", - "NominalPipeSize": 18.0, + "PipeSize": "18", + "PipeClearance": 0.0625, "OD": 29.25, "HoleDiameter": 1.75, "HolePatternDiameter": 25.75, @@ -977,55 +1099,62 @@ }, { "Name": "18in-900#", - "NominalPipeSize": 18.0, - "OD": 31.0, - "HoleDiameter": 2.0, - "HolePatternDiameter": 27.0, + "PipeSize": "18", + "PipeClearance": 0.0625, + "OD": 31, + "HoleDiameter": 2, + "HolePatternDiameter": 27, "HoleCount": 20 }, { "Name": "18in-1500#", - "NominalPipeSize": 18.0, - "OD": 36.0, + "PipeSize": "18", + "PipeClearance": 0.0625, + "OD": 36, "HoleDiameter": 2.88, "HolePatternDiameter": 30.5, "HoleCount": 16 }, { "Name": "20in-150#", - "NominalPipeSize": 20.0, + "PipeSize": "20", + "PipeClearance": 0.0625, "OD": 27.5, "HoleDiameter": 1.25, - "HolePatternDiameter": 25.0, + "HolePatternDiameter": 25, "HoleCount": 20 }, { "Name": "20in-300#", - "NominalPipeSize": 20.0, + "PipeSize": "20", + "PipeClearance": 0.0625, "OD": 30.5, "HoleDiameter": 1.38, - "HolePatternDiameter": 27.0, + "HolePatternDiameter": 27, "HoleCount": 24 }, { "Name": "20in-400#", - "NominalPipeSize": 20.0, + "PipeSize": "20", + "PipeClearance": 0.0625, "OD": 30.5, "HoleDiameter": 1.62, - "HolePatternDiameter": 27.0, + "HolePatternDiameter": 27, "HoleCount": 24 }, { "Name": "20in-600#", - "NominalPipeSize": 20.0, - "OD": 32.0, + "PipeSize": "20", + "PipeClearance": 0.0625, + "OD": 32, "HoleDiameter": 1.75, "HolePatternDiameter": 28.5, "HoleCount": 24 }, { "Name": "20in-900#", - "NominalPipeSize": 20.0, + "PipeSize": "20", + "PipeClearance": 0.0625, "OD": 33.75, "HoleDiameter": 2.12, "HolePatternDiameter": 29.5, @@ -1033,7 +1162,8 @@ }, { "Name": "20in-1500#", - "NominalPipeSize": 20.0, + "PipeSize": "20", + "PipeClearance": 0.0625, "OD": 38.75, "HoleDiameter": 3.12, "HolePatternDiameter": 32.75, @@ -1041,50 +1171,56 @@ }, { "Name": "24in-150#", - "NominalPipeSize": 24.0, - "OD": 32.0, + "PipeSize": "24", + "PipeClearance": 0.0625, + "OD": 32, "HoleDiameter": 1.38, "HolePatternDiameter": 29.5, "HoleCount": 20 }, { "Name": "24in-300#", - "NominalPipeSize": 24.0, - "OD": 36.0, + "PipeSize": "24", + "PipeClearance": 0.0625, + "OD": 36, "HoleDiameter": 1.62, - "HolePatternDiameter": 32.0, + "HolePatternDiameter": 32, "HoleCount": 24 }, { "Name": "24in-400#", - "NominalPipeSize": 24.0, - "OD": 36.0, + "PipeSize": "24", + "PipeClearance": 0.0625, + "OD": 36, "HoleDiameter": 1.88, - "HolePatternDiameter": 32.0, + "HolePatternDiameter": 32, "HoleCount": 24 }, { "Name": "24in-600#", - "NominalPipeSize": 24.0, - "OD": 37.0, - "HoleDiameter": 2.0, - "HolePatternDiameter": 33.0, + "PipeSize": "24", + "PipeClearance": 0.0625, + "OD": 37, + "HoleDiameter": 2, + "HolePatternDiameter": 33, "HoleCount": 24 }, { "Name": "24in-900#", - "NominalPipeSize": 24.0, - "OD": 41.0, + "PipeSize": "24", + "PipeClearance": 0.0625, + "OD": 41, "HoleDiameter": 2.62, "HolePatternDiameter": 35.5, "HoleCount": 20 }, { "Name": "24in-1500#", - "NominalPipeSize": 24.0, - "OD": 46.0, + "PipeSize": "24", + "PipeClearance": 0.0625, + "OD": 46, "HoleDiameter": 3.62, - "HolePatternDiameter": 39.0, + "HolePatternDiameter": 39, "HoleCount": 16 } ] \ No newline at end of file From eddbbca7efa93b06029250c018cfebe5901ef20e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 17:45:46 -0400 Subject: [PATCH 07/20] test(shapes): verify PipeFlangeShape JSON loading and shipped config integrity --- OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs b/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs index fbf3b37..5d0e950 100644 --- a/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs +++ b/OpenNest.Tests/Shapes/PipeFlangeShapeTests.cs @@ -1,3 +1,5 @@ +using System; +using System.IO; using OpenNest.Shapes; namespace OpenNest.Tests.Shapes; @@ -133,4 +135,82 @@ public class PipeFlangeShapeTests var expectedArea = System.Math.PI * 24; Assert.Equal(expectedArea, drawing.Area, 0.5); } + + [Fact] + public void LoadFromJson_ProducesCorrectDrawing() + { + var json = """ + [ + { + "Name": "2in-150#", + "PipeSize": "2", + "PipeClearance": 0.0625, + "OD": 6.0, + "HoleDiameter": 0.75, + "HolePatternDiameter": 4.75, + "HoleCount": 4 + }, + { + "Name": "2in-300#", + "PipeSize": "2", + "PipeClearance": 0.0625, + "OD": 6.5, + "HoleDiameter": 0.75, + "HolePatternDiameter": 5.0, + "HoleCount": 8 + } + ] + """; + + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, json); + + var flanges = ShapeDefinition.LoadFromJson(tempFile); + + Assert.Equal(2, flanges.Count); + + var first = flanges[0]; + Assert.Equal("2in-150#", first.Name); + Assert.Equal("2", first.PipeSize); + Assert.Equal(0.0625, first.PipeClearance, 0.0001); + var drawing = first.GetDrawing(); + var bbox = drawing.Program.BoundingBox(); + Assert.Equal(6, bbox.Width, 0.01); + + var second = flanges[1]; + Assert.Equal("2in-300#", second.Name); + Assert.Equal(8, second.HoleCount); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void LoadFromJson_RealShippedConfig_LoadsAllEntries() + { + // Resolve the repo-relative config path from the test binary location. + var dir = AppDomain.CurrentDomain.BaseDirectory; + while (dir != null && !File.Exists(Path.Combine(dir, "OpenNest.sln"))) + dir = Path.GetDirectoryName(dir); + + Assert.NotNull(dir); + + var configPath = Path.Combine(dir, "OpenNest", "Configurations", "PipeFlangeShape.json"); + Assert.True(File.Exists(configPath), $"Config missing at {configPath}"); + + var flanges = ShapeDefinition.LoadFromJson(configPath); + + Assert.NotEmpty(flanges); + foreach (var f in flanges) + { + Assert.False(string.IsNullOrWhiteSpace(f.PipeSize)); + Assert.True(PipeSizes.TryGetOD(f.PipeSize, out _), + $"Unknown PipeSize '{f.PipeSize}' in entry '{f.Name}'"); + Assert.Equal(0.0625, f.PipeClearance, 0.0001); + } + } } From 9d66b78a11aec4f9c3b36366a3fde5a6638c5789 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 17:47:36 -0400 Subject: [PATCH 08/20] feat(ui): add bool checkbox support to ShapeLibraryForm BuildParameterControls now creates a CheckBox (wired to UpdatePreview) for bool properties instead of a TextBox; CreateShapeFromInputs reads the Checked value via a short-circuit before the TextBox cast. Co-Authored-By: Claude Sonnet 4.6 --- OpenNest/Forms/ShapeLibraryForm.cs | 53 +++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/OpenNest/Forms/ShapeLibraryForm.cs b/OpenNest/Forms/ShapeLibraryForm.cs index 0b98dec..730b11e 100644 --- a/OpenNest/Forms/ShapeLibraryForm.cs +++ b/OpenNest/Forms/ShapeLibraryForm.cs @@ -180,27 +180,43 @@ namespace OpenNest.Forms y += 18; - var tb = new TextBox + Control editor; + if (prop.PropertyType == typeof(bool)) { - Location = new Point(parametersPanel.Padding.Left, y), - Width = panelWidth, - Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right - }; + var cb = new CheckBox + { + Location = new Point(parametersPanel.Padding.Left, y), + AutoSize = true, + Checked = sourceValues != null && (bool)prop.GetValue(sourceValues) + }; + cb.CheckedChanged += (s, ev) => UpdatePreview(); + editor = cb; + } + else + { + var tb = new TextBox + { + Location = new Point(parametersPanel.Padding.Left, y), + Width = panelWidth, + Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right + }; - if (sourceValues != null) - { - if (prop.PropertyType == typeof(int)) - tb.Text = ((int)prop.GetValue(sourceValues)).ToString(); - else - tb.Text = ((double)prop.GetValue(sourceValues)).ToString("G"); + if (sourceValues != null) + { + if (prop.PropertyType == typeof(int)) + tb.Text = ((int)prop.GetValue(sourceValues)).ToString(); + else + tb.Text = ((double)prop.GetValue(sourceValues)).ToString("G"); + } + + tb.TextChanged += (s, ev) => UpdatePreview(); + editor = tb; } - tb.TextChanged += (s, ev) => UpdatePreview(); - - parameterBindings.Add(new ParameterBinding { Property = prop, Control = tb }); + parameterBindings.Add(new ParameterBinding { Property = prop, Control = editor }); parametersPanel.Controls.Add(label); - parametersPanel.Controls.Add(tb); + parametersPanel.Controls.Add(editor); y += 30; } @@ -241,6 +257,13 @@ namespace OpenNest.Forms foreach (var binding in parameterBindings) { + if (binding.Property.PropertyType == typeof(bool)) + { + var cb = (CheckBox)binding.Control; + binding.Property.SetValue(shape, cb.Checked); + continue; + } + var tb = (TextBox)binding.Control; if (binding.Property.PropertyType == typeof(int)) From b1d094104af233ee1c2e61050e4bd8de2cbd8712 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 17:50:01 -0400 Subject: [PATCH 09/20] feat(ui): add filtered pipe size dropdown to shape library Renders PipeSize as a DropDownList ComboBox, filters entries to those fitting the current hole geometry, disables the combo when Blind is checked, and appends an invalid-pipe warning to the preview info when TryGetOD fails. Co-Authored-By: Claude Sonnet 4.6 --- OpenNest/Forms/ShapeLibraryForm.cs | 112 ++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 3 deletions(-) diff --git a/OpenNest/Forms/ShapeLibraryForm.cs b/OpenNest/Forms/ShapeLibraryForm.cs index 730b11e..1a8b943 100644 --- a/OpenNest/Forms/ShapeLibraryForm.cs +++ b/OpenNest/Forms/ShapeLibraryForm.cs @@ -192,6 +192,29 @@ namespace OpenNest.Forms cb.CheckedChanged += (s, ev) => UpdatePreview(); editor = cb; } + else if (prop.PropertyType == typeof(string) && prop.Name == "PipeSize") + { + var combo = new ComboBox + { + Location = new Point(parametersPanel.Padding.Left, y), + Width = panelWidth, + Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right, + DropDownStyle = ComboBoxStyle.DropDownList + }; + + // Initial population: every entry; the filter runs on first UpdatePreview. + foreach (var entry in PipeSizes.All) + combo.Items.Add(entry.Label); + + var initial = sourceValues != null ? (string)prop.GetValue(sourceValues) : null; + if (!string.IsNullOrEmpty(initial) && combo.Items.Contains(initial)) + combo.SelectedItem = initial; + else if (combo.Items.Count > 0) + combo.SelectedIndex = 0; + + combo.SelectedIndexChanged += (s, ev) => UpdatePreview(); + editor = combo; + } else { var tb = new TextBox @@ -228,6 +251,8 @@ namespace OpenNest.Forms { if (suppressPreview || selectedEntry == null) return; + UpdatePipeSizeFilter(); + try { var shape = CreateShapeFromInputs(); @@ -239,9 +264,17 @@ namespace OpenNest.Forms if (drawing?.Program != null) { var bb = drawing.Program.BoundingBox(); - previewBox.SetInfo( - nameTextBox.Text, - string.Format("{0:F3} x {1:F3}", bb.Size.Length, bb.Size.Width)); + var info = string.Format("{0:F3} x {1:F3}", bb.Size.Length, bb.Size.Width); + + if (shape is PipeFlangeShape flange + && !flange.Blind + && !string.IsNullOrEmpty(flange.PipeSize) + && !PipeSizes.TryGetOD(flange.PipeSize, out _)) + { + info += " — Invalid pipe size, no bore cut"; + } + + previewBox.SetInfo(nameTextBox.Text, info); } } catch @@ -250,6 +283,72 @@ namespace OpenNest.Forms } } + private void UpdatePipeSizeFilter() + { + // Find the PipeSize combo and the numeric inputs it depends on. + ComboBox pipeCombo = null; + double holePattern = 0, holeDia = 0, clearance = 0; + bool blind = false; + + foreach (var binding in parameterBindings) + { + var name = binding.Property.Name; + if (name == "PipeSize" && binding.Control is ComboBox cb) + pipeCombo = cb; + else if (name == "HolePatternDiameter" && binding.Control is TextBox tb1) + double.TryParse(tb1.Text, out holePattern); + else if (name == "HoleDiameter" && binding.Control is TextBox tb2) + double.TryParse(tb2.Text, out holeDia); + else if (name == "PipeClearance" && binding.Control is TextBox tb3) + double.TryParse(tb3.Text, out clearance); + else if (name == "Blind" && binding.Control is CheckBox chk) + blind = chk.Checked; + } + + if (pipeCombo == null) + return; + + // Disable when blind, but keep visible with the selection preserved. + pipeCombo.Enabled = !blind; + + // Compute filter: pipeOD + clearance < HolePatternDiameter - HoleDiameter. + var maxPipeOD = holePattern - holeDia - clearance; + var fittingLabels = PipeSizes.GetFittingSizes(maxPipeOD).Select(e => e.Label).ToList(); + + // Sequence-equal on existing items — no-op if unchanged (avoids flicker). + var currentLabels = pipeCombo.Items.Cast().ToList(); + if (currentLabels.SequenceEqual(fittingLabels)) + return; + + var previousSelection = pipeCombo.SelectedItem as string; + + pipeCombo.BeginUpdate(); + try + { + pipeCombo.Items.Clear(); + foreach (var label in fittingLabels) + pipeCombo.Items.Add(label); + + if (fittingLabels.Count == 0) + { + // No pipe fits — leave unselected. + } + else if (previousSelection != null && fittingLabels.Contains(previousSelection)) + { + pipeCombo.SelectedItem = previousSelection; + } + else + { + // Select the largest (last, since PipeSizes.All is sorted ascending). + pipeCombo.SelectedIndex = fittingLabels.Count - 1; + } + } + finally + { + pipeCombo.EndUpdate(); + } + } + private ShapeDefinition CreateShapeFromInputs() { var shape = (ShapeDefinition)Activator.CreateInstance(selectedEntry.ShapeType); @@ -264,6 +363,13 @@ namespace OpenNest.Forms continue; } + if (binding.Property.PropertyType == typeof(string)) + { + var combo = (ComboBox)binding.Control; + binding.Property.SetValue(shape, combo.SelectedItem?.ToString()); + continue; + } + var tb = (TextBox)binding.Control; if (binding.Property.PropertyType == typeof(int)) From 54def611fa12676607cfe7424e59fc69c01db007 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 17:52:03 -0400 Subject: [PATCH 10/20] refactor(ui): switch CreateShapeFromInputs to control-type branching --- OpenNest/Forms/ShapeLibraryForm.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/OpenNest/Forms/ShapeLibraryForm.cs b/OpenNest/Forms/ShapeLibraryForm.cs index 1a8b943..3c9e6bd 100644 --- a/OpenNest/Forms/ShapeLibraryForm.cs +++ b/OpenNest/Forms/ShapeLibraryForm.cs @@ -363,9 +363,8 @@ namespace OpenNest.Forms continue; } - if (binding.Property.PropertyType == typeof(string)) + if (binding.Control is ComboBox combo) { - var combo = (ComboBox)binding.Control; binding.Property.SetValue(shape, combo.SelectedItem?.ToString()); continue; } From 0e45c135159aedfe0fee90f1dcc4c55bf60b7317 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 20:16:29 -0400 Subject: [PATCH 11/20] feat(shapes): add PlateSizes catalog and wire Ctrl+P to snap-to-standard PlateSizes holds standard mill sheet sizes (48x96 through 96x240) and exposes Recommend() which snaps small layouts to an increment and rounds larger layouts up to the nearest fitting sheet. Plate.SnapToStandardSize applies the result while preserving long-axis orientation, and the existing Ctrl+P "Resize to Fit" menu in EditNestForm now calls it instead of the simple round-up AutoSize. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Plate.cs | 60 ++++ OpenNest.Core/Shapes/PlateSizes.cs | 255 ++++++++++++++ .../PlateSnapToStandardSizeTests.cs | 118 +++++++ OpenNest.Tests/Shapes/PlateSizesTests.cs | 311 ++++++++++++++++++ OpenNest/Forms/EditNestForm.cs | 7 +- 5 files changed, 750 insertions(+), 1 deletion(-) create mode 100644 OpenNest.Core/Shapes/PlateSizes.cs create mode 100644 OpenNest.Tests/PlateSnapToStandardSizeTests.cs create mode 100644 OpenNest.Tests/Shapes/PlateSizesTests.cs diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 8e58ea9..e5a3714 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -1,6 +1,7 @@ using OpenNest.Collections; using OpenNest.Geometry; using OpenNest.Math; +using OpenNest.Shapes; using System; using System.Collections.Generic; using System.Linq; @@ -548,6 +549,65 @@ namespace OpenNest Rounding.RoundUpToNearest(xExtent, roundingFactor)); } + /// + /// Sizes the plate using the catalog: small + /// layouts snap to an increment, larger ones round up to the next + /// standard mill sheet. The plate's long-axis orientation (X vs Y) + /// is preserved. Does nothing if the plate has no parts. + /// + public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null) + { + if (Parts.Count == 0) + return default; + + var bounds = Parts.GetBoundingBox(); + + // Quadrant-aware extents relative to the plate origin, matching AutoSize. + double xExtent; + double yExtent; + + switch (Quadrant) + { + case 1: + xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right; + yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top; + break; + + case 2: + xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left; + yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top; + break; + + case 3: + xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left; + yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom; + break; + + case 4: + xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right; + yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom; + break; + + default: + return default; + } + + // PlateSizes.Recommend takes (short, long); canonicalize then map + // the result back so the plate's long axis stays aligned with the + // parts' long axis. + var shortDim = System.Math.Min(xExtent, yExtent); + var longDim = System.Math.Max(xExtent, yExtent); + var result = PlateSizes.Recommend(shortDim, longDim, options); + + // Plate convention: Length = X axis, Width = Y axis. + if (xExtent >= yExtent) + Size = new Size(result.Width, result.Length); // X is the long axis + else + Size = new Size(result.Length, result.Width); // Y is the long axis + + return result; + } + /// /// Gets the area of the top surface of the plate. /// diff --git a/OpenNest.Core/Shapes/PlateSizes.cs b/OpenNest.Core/Shapes/PlateSizes.cs new file mode 100644 index 0000000..08af25d --- /dev/null +++ b/OpenNest.Core/Shapes/PlateSizes.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.Shapes +{ + /// + /// Catalog of standard mill sheet sizes (inches) with helpers for matching + /// a bounding box to a recommended plate size. Uses the project-wide + /// (Width, Length) convention where Width is the short dimension and + /// Length is the long dimension. + /// + public static class PlateSizes + { + public readonly record struct Entry(string Label, double Width, double Length) + { + public double Area => Width * Length; + + /// + /// Returns true if a part of the given dimensions fits within this entry + /// in either orientation. + /// + public bool Fits(double width, double length) => + (width <= Width && length <= Length) || (width <= Length && length <= Width); + } + + /// + /// Standard mill sheet sizes (inches), sorted by area ascending. + /// Canonical orientation: Width <= Length. + /// + public static IReadOnlyList All { get; } = new[] + { + new Entry("48x96", 48, 96), // 4608 + new Entry("48x120", 48, 120), // 5760 + new Entry("48x144", 48, 144), // 6912 + new Entry("60x120", 60, 120), // 7200 + new Entry("60x144", 60, 144), // 8640 + new Entry("72x120", 72, 120), // 8640 + new Entry("72x144", 72, 144), // 10368 + new Entry("96x240", 96, 240), // 23040 + }; + + /// + /// Looks up a standard size by label. Case-insensitive. + /// + public static bool TryGet(string label, out Entry entry) + { + if (!string.IsNullOrWhiteSpace(label)) + { + foreach (var candidate in All) + { + if (string.Equals(candidate.Label, label, StringComparison.OrdinalIgnoreCase)) + { + entry = candidate; + return true; + } + } + } + + entry = default; + return false; + } + + /// + /// Recommends a plate size for the given bounding box. The box's + /// spatial axes are normalized to (short, long) so neither the bbox + /// orientation nor Box's internal Length/Width naming matters. + /// + public static PlateSizeResult Recommend(Box bbox, PlateSizeOptions options = null) + { + var a = bbox.Width; + var b = bbox.Length; + return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options); + } + + /// + /// Recommends a plate size for the envelope of the given boxes. + /// + public static PlateSizeResult Recommend(IEnumerable boxes, PlateSizeOptions options = null) + { + if (boxes == null) + throw new ArgumentNullException(nameof(boxes)); + + var hasAny = false; + var minX = double.PositiveInfinity; + var minY = double.PositiveInfinity; + var maxX = double.NegativeInfinity; + var maxY = double.NegativeInfinity; + + foreach (var box in boxes) + { + hasAny = true; + if (box.Left < minX) minX = box.Left; + if (box.Bottom < minY) minY = box.Bottom; + if (box.Right > maxX) maxX = box.Right; + if (box.Top > maxY) maxY = box.Top; + } + + if (!hasAny) + throw new ArgumentException("At least one box is required.", nameof(boxes)); + + var b = maxX - minX; + var a = maxY - minY; + return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options); + } + + /// + /// Recommends a plate size for a (width, length) pair. + /// Inputs are treated as orientation-independent. + /// + public static PlateSizeResult Recommend(double width, double length, PlateSizeOptions options = null) + { + options ??= new PlateSizeOptions(); + + var w = width + 2 * options.Margin; + var l = length + 2 * options.Margin; + + // Canonicalize (short, long) — Fits handles rotation anyway, but + // normalizing lets the below-min comparison use the narrower + // MinSheet dimensions consistently. + if (w > l) + (w, l) = (l, w); + + // Below full-sheet threshold: snap each dimension up to the nearest increment. + if (w <= options.MinSheetWidth && l <= options.MinSheetLength) + return SnapResult(w, l, options.SnapIncrement); + + var catalog = BuildCatalog(options.AllowedSizes); + + var best = PickBest(catalog, w, l, options.Selection); + if (best.HasValue) + return new PlateSizeResult(best.Value.Width, best.Value.Length, best.Value.Label); + + // Nothing in the catalog fits - fall back to snap-up (ad-hoc oversize sheet). + return SnapResult(w, l, options.SnapIncrement); + } + + private static PlateSizeResult SnapResult(double width, double length, double increment) + { + if (increment <= 0) + return new PlateSizeResult(width, length, null); + + return new PlateSizeResult(SnapUp(width, increment), SnapUp(length, increment), null); + } + + private static double SnapUp(double value, double increment) + { + var steps = System.Math.Ceiling(value / increment); + return steps * increment; + } + + private static IReadOnlyList BuildCatalog(IReadOnlyList allowedSizes) + { + if (allowedSizes == null || allowedSizes.Count == 0) + return All; + + var result = new List(allowedSizes.Count); + foreach (var label in allowedSizes) + { + if (TryParseEntry(label, out var entry)) + result.Add(entry); + } + + return result; + } + + private static bool TryParseEntry(string label, out Entry entry) + { + if (TryGet(label, out entry)) + return true; + + // Accept ad-hoc "WxL" strings (e.g. "50x100", "50 x 100"). + if (!string.IsNullOrWhiteSpace(label)) + { + var parts = label.Split(new[] { 'x', 'X' }, 2); + if (parts.Length == 2 + && double.TryParse(parts[0].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var a) + && double.TryParse(parts[1].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var b) + && a > 0 && b > 0) + { + var width = System.Math.Min(a, b); + var length = System.Math.Max(a, b); + entry = new Entry(label.Trim(), width, length); + return true; + } + } + + entry = default; + return false; + } + + private static Entry? PickBest(IReadOnlyList catalog, double width, double length, PlateSizeSelection selection) + { + var fitting = catalog.Where(e => e.Fits(width, length)); + + fitting = selection switch + { + PlateSizeSelection.NarrowestFirst => fitting.OrderBy(e => e.Width).ThenBy(e => e.Area), + _ => fitting.OrderBy(e => e.Area).ThenBy(e => e.Width), + }; + + foreach (var candidate in fitting) + return candidate; + + return null; + } + } + + public readonly record struct PlateSizeResult(double Width, double Length, string MatchedLabel) + { + public bool IsStandard => MatchedLabel != null; + } + + public sealed class PlateSizeOptions + { + /// + /// If the margin-adjusted bounding box fits within MinSheetWidth x MinSheetLength + /// the result is snapped to instead of routed to a + /// standard sheet. Default 48" x 48". + /// + public double MinSheetWidth { get; set; } = 48; + public double MinSheetLength { get; set; } = 48; + + /// + /// Increment used for below-threshold rounding and oversize fallback. Default 1". + /// + public double SnapIncrement { get; set; } = 1.0; + + /// + /// Extra clearance added to each side of the bounding box before matching. + /// + public double Margin { get; set; } = 0; + + /// + /// Optional whitelist. When non-empty, only these sizes are considered. + /// Entries may be standard catalog labels (e.g. "48x96") or arbitrary + /// "WxL" strings (e.g. "50x100"). + /// + public IReadOnlyList AllowedSizes { get; set; } + + /// + /// Tiebreaker when multiple sheets can contain the bounding box. + /// + public PlateSizeSelection Selection { get; set; } = PlateSizeSelection.SmallestArea; + } + + public enum PlateSizeSelection + { + /// Pick the cheapest sheet that contains the bbox (smallest area). + SmallestArea, + /// Prefer narrower-width sheets (e.g. 48-wide before 60-wide). + NarrowestFirst, + } +} diff --git a/OpenNest.Tests/PlateSnapToStandardSizeTests.cs b/OpenNest.Tests/PlateSnapToStandardSizeTests.cs new file mode 100644 index 0000000..b4a9cd1 --- /dev/null +++ b/OpenNest.Tests/PlateSnapToStandardSizeTests.cs @@ -0,0 +1,118 @@ +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.Shapes; + +namespace OpenNest.Tests; + +public class PlateSnapToStandardSizeTests +{ + private static Part MakeRectPart(double x, double y, double length, double width) + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(length, 0))); + pgm.Codes.Add(new LinearMove(new Vector(length, width))); + pgm.Codes.Add(new LinearMove(new Vector(0, width))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + var drawing = new Drawing("test", pgm); + var part = new Part(drawing); + part.Offset(x, y); + return part; + } + + [Fact] + public void SnapToStandardSize_SmallParts_SnapsToIncrement() + { + var plate = new Plate(200, 200); // oversized starting size + plate.Parts.Add(MakeRectPart(0, 0, 10, 20)); + + var result = plate.SnapToStandardSize(); + + // 10x20 is well below 48x48 MinSheet -> snap to integer increment. + Assert.Null(result.MatchedLabel); + Assert.Equal(10, plate.Size.Length); // X axis + Assert.Equal(20, plate.Size.Width); // Y axis + } + + [Fact] + public void SnapToStandardSize_SmallPartsWithFractionalIncrement_UsesIncrement() + { + var plate = new Plate(200, 200); + plate.Parts.Add(MakeRectPart(0, 0, 10.3, 20.7)); + + var result = plate.SnapToStandardSize(new PlateSizeOptions { SnapIncrement = 0.25 }); + + Assert.Null(result.MatchedLabel); + Assert.Equal(10.5, plate.Size.Length, 4); + Assert.Equal(20.75, plate.Size.Width, 4); + } + + [Fact] + public void SnapToStandardSize_40x90Part_SnapsToStandard48x96_XLong() + { + // Part is 90 long (X) x 40 wide (Y) -> X is the long axis. + var plate = new Plate(200, 200); + plate.Parts.Add(MakeRectPart(0, 0, 90, 40)); + + var result = plate.SnapToStandardSize(); + + Assert.Equal("48x96", result.MatchedLabel); + Assert.Equal(96, plate.Size.Length); // X axis = long + Assert.Equal(48, plate.Size.Width); // Y axis = short + } + + [Fact] + public void SnapToStandardSize_90TallPart_SnapsToStandard48x96_YLong() + { + // Part is 40 long (X) x 90 wide (Y) -> Y is the long axis. + var plate = new Plate(200, 200); + plate.Parts.Add(MakeRectPart(0, 0, 40, 90)); + + var result = plate.SnapToStandardSize(); + + Assert.Equal("48x96", result.MatchedLabel); + Assert.Equal(48, plate.Size.Length); // X axis = short + Assert.Equal(96, plate.Size.Width); // Y axis = long + } + + [Fact] + public void SnapToStandardSize_JustOver48_PicksNextStandardSize() + { + var plate = new Plate(200, 200); + plate.Parts.Add(MakeRectPart(0, 0, 100, 50)); + + var result = plate.SnapToStandardSize(); + + Assert.Equal("60x120", result.MatchedLabel); + Assert.Equal(120, plate.Size.Length); // X long + Assert.Equal(60, plate.Size.Width); + } + + [Fact] + public void SnapToStandardSize_EmptyPlate_DoesNotModifySize() + { + var plate = new Plate(60, 120); + + var result = plate.SnapToStandardSize(); + + Assert.Null(result.MatchedLabel); + Assert.Equal(60, plate.Size.Width); + Assert.Equal(120, plate.Size.Length); + } + + [Fact] + public void SnapToStandardSize_MultipleParts_UsesCombinedEnvelope() + { + var plate = new Plate(200, 200); + plate.Parts.Add(MakeRectPart(0, 0, 30, 40)); + plate.Parts.Add(MakeRectPart(30, 0, 30, 40)); // combined X-extent = 60 + plate.Parts.Add(MakeRectPart(0, 40, 60, 60)); // combined extent = 60 x 100 + + var result = plate.SnapToStandardSize(); + + // 60 x 100 fits 60x120 standard sheet, Y is the long axis. + Assert.Equal("60x120", result.MatchedLabel); + Assert.Equal(60, plate.Size.Length); // X + Assert.Equal(120, plate.Size.Width); // Y long + } +} diff --git a/OpenNest.Tests/Shapes/PlateSizesTests.cs b/OpenNest.Tests/Shapes/PlateSizesTests.cs new file mode 100644 index 0000000..7b3e0a7 --- /dev/null +++ b/OpenNest.Tests/Shapes/PlateSizesTests.cs @@ -0,0 +1,311 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; +using OpenNest.Shapes; + +namespace OpenNest.Tests.Shapes; + +public class PlateSizesTests +{ + [Fact] + public void All_IsNotEmpty() + { + Assert.NotEmpty(PlateSizes.All); + } + + [Fact] + public void All_DoesNotContain48x48() + { + // 48x48 is not a standard sheet - it's the default MinSheet threshold only. + Assert.DoesNotContain(PlateSizes.All, e => e.Width == 48 && e.Length == 48); + } + + [Fact] + public void All_Smallest_Is48x96() + { + var smallest = PlateSizes.All.OrderBy(e => e.Area).First(); + Assert.Equal(48, smallest.Width); + Assert.Equal(96, smallest.Length); + } + + [Fact] + public void All_SortedByAreaAscending() + { + for (var i = 1; i < PlateSizes.All.Count; i++) + Assert.True(PlateSizes.All[i].Area >= PlateSizes.All[i - 1].Area); + } + + [Fact] + public void All_Entries_AreCanonical_WidthLessOrEqualLength() + { + foreach (var entry in PlateSizes.All) + Assert.True(entry.Width <= entry.Length, $"{entry.Label} not in canonical orientation"); + } + + [Theory] + [InlineData(40, 40, true)] // small - fits trivially + [InlineData(48, 96, true)] // exact + [InlineData(96, 48, true)] // rotated exact + [InlineData(90, 40, true)] // rotated + [InlineData(49, 97, false)] // just over in both dims + [InlineData(50, 50, false)] // too wide in both orientations + public void Entry_Fits_RespectsRotation(double w, double h, bool expected) + { + var entry = new PlateSizes.Entry("48x96", 48, 96); + Assert.Equal(expected, entry.Fits(w, h)); + } + + [Fact] + public void TryGet_KnownLabel_ReturnsEntry() + { + Assert.True(PlateSizes.TryGet("48x96", out var entry)); + Assert.Equal(48, entry.Width); + Assert.Equal(96, entry.Length); + } + + [Fact] + public void TryGet_IsCaseInsensitive() + { + Assert.True(PlateSizes.TryGet("48X96", out var entry)); + Assert.Equal(48, entry.Width); + Assert.Equal(96, entry.Length); + } + + [Fact] + public void TryGet_UnknownLabel_ReturnsFalse() + { + Assert.False(PlateSizes.TryGet("bogus", out _)); + } + + [Fact] + public void Recommend_BelowMin_SnapsToDefaultIncrementOfOne() + { + var bbox = new Box(0, 0, 10.3, 20.7); + + var result = PlateSizes.Recommend(bbox); + + Assert.Equal(11, result.Width); + Assert.Equal(21, result.Length); + Assert.Null(result.MatchedLabel); + } + + [Fact] + public void Recommend_BelowMin_UsesCustomIncrement() + { + var bbox = new Box(0, 0, 10.3, 20.7); + var options = new PlateSizeOptions { SnapIncrement = 0.25 }; + + var result = PlateSizes.Recommend(bbox, options); + + Assert.Equal(10.5, result.Width, 4); + Assert.Equal(20.75, result.Length, 4); + Assert.Null(result.MatchedLabel); + } + + [Fact] + public void Recommend_ExactlyAtMin_Snaps() + { + var bbox = new Box(0, 0, 48, 48); + + var result = PlateSizes.Recommend(bbox); + + Assert.Equal(48, result.Width); + Assert.Equal(48, result.Length); + Assert.Null(result.MatchedLabel); + } + + [Fact] + public void Recommend_AboveMin_PicksSmallestContainingStandardSheet() + { + var bbox = new Box(0, 0, 40, 90); + + var result = PlateSizes.Recommend(bbox); + + Assert.Equal(48, result.Width); + Assert.Equal(96, result.Length); + Assert.Equal("48x96", result.MatchedLabel); + } + + [Fact] + public void Recommend_AboveMin_WithRotation_PicksSmallestSheet() + { + var bbox = new Box(0, 0, 90, 40); + + var result = PlateSizes.Recommend(bbox); + + Assert.Equal("48x96", result.MatchedLabel); + } + + [Fact] + public void Recommend_JustOver48_PicksNextStandardSize() + { + var bbox = new Box(0, 0, 50, 100); + + var result = PlateSizes.Recommend(bbox); + + Assert.Equal(60, result.Width); + Assert.Equal(120, result.Length); + Assert.Equal("60x120", result.MatchedLabel); + } + + [Fact] + public void Recommend_MarginIsAppliedPerSide() + { + // 46 + 2*1 = 48 (fits exactly), 94 + 2*1 = 96 (fits exactly) + var bbox = new Box(0, 0, 46, 94); + var options = new PlateSizeOptions { Margin = 1 }; + + var result = PlateSizes.Recommend(bbox, options); + + Assert.Equal("48x96", result.MatchedLabel); + } + + [Fact] + public void Recommend_MarginPushesToNextSheet() + { + // 47 + 2 = 49 > 48, so 48x96 no longer fits -> next standard + var bbox = new Box(0, 0, 47, 95); + var options = new PlateSizeOptions { Margin = 1 }; + + var result = PlateSizes.Recommend(bbox, options); + + Assert.NotEqual("48x96", result.MatchedLabel); + Assert.True(result.Width >= 49); + Assert.True(result.Length >= 97); + } + + [Fact] + public void Recommend_AllowedSizes_StandardLabelWhitelist() + { + // 60x120 is the only option; 50x50 is above min so it routes to standard + var bbox = new Box(0, 0, 50, 50); + var options = new PlateSizeOptions { AllowedSizes = new[] { "60x120" } }; + + var result = PlateSizes.Recommend(bbox, options); + + Assert.Equal("60x120", result.MatchedLabel); + } + + [Fact] + public void Recommend_AllowedSizes_ArbitraryWxHString() + { + // 50x100 isn't in the standard catalog but is valid as an ad-hoc entry. + // bbox 49x99 doesn't fit 48x96 or 48x120, does fit 50x100 and 60x120, + // but only 50x100 is allowed. + var bbox = new Box(0, 0, 49, 99); + var options = new PlateSizeOptions { AllowedSizes = new[] { "50x100" } }; + + var result = PlateSizes.Recommend(bbox, options); + + Assert.Equal(50, result.Width); + Assert.Equal(100, result.Length); + Assert.Equal("50x100", result.MatchedLabel); + } + + [Fact] + public void Recommend_NothingFits_FallsBackToSnapUp() + { + // Larger than any catalog sheet + var bbox = new Box(0, 0, 100, 300); + + var result = PlateSizes.Recommend(bbox); + + Assert.Equal(100, result.Width); + Assert.Equal(300, result.Length); + Assert.Null(result.MatchedLabel); + } + + [Fact] + public void Recommend_NothingFitsInAllowedList_FallsBackToSnapUp() + { + // Only 48x96 allowed, but bbox is too big for it + var bbox = new Box(0, 0, 50, 100); + var options = new PlateSizeOptions { AllowedSizes = new[] { "48x96" } }; + + var result = PlateSizes.Recommend(bbox, options); + + Assert.Equal(50, result.Width); + Assert.Equal(100, result.Length); + Assert.Null(result.MatchedLabel); + } + + [Fact] + public void Recommend_BoxEnumerable_CombinesIntoEnvelope() + { + // Two boxes that together span 0..40 x 0..90 -> fits 48x96 + var boxes = new[] + { + new Box(0, 0, 40, 50), + new Box(0, 40, 30, 50), + }; + + var result = PlateSizes.Recommend(boxes); + + Assert.Equal("48x96", result.MatchedLabel); + } + + [Fact] + public void Recommend_BoxEnumerable_Empty_Throws() + { + Assert.Throws( + () => PlateSizes.Recommend(System.Array.Empty())); + } + + [Fact] + public void PlateSizeOptions_Defaults() + { + var options = new PlateSizeOptions(); + + Assert.Equal(48, options.MinSheetWidth); + Assert.Equal(48, options.MinSheetLength); + Assert.Equal(1.0, options.SnapIncrement); + Assert.Equal(0, options.Margin); + Assert.Null(options.AllowedSizes); + Assert.Equal(PlateSizeSelection.SmallestArea, options.Selection); + } + + [Fact] + public void Recommend_NarrowestFirst_PicksNarrowerSheetOverSmallerArea() + { + // Hypothetical: bbox (47, 47) fits both 48x96 (area 4608) and some narrower option. + // With SmallestArea: picks 48x96 (it's already the smallest 48-wide). + // With NarrowestFirst: also picks 48x96 since that's the narrowest. + // Better test: AllowedSizes = ["60x120", "48x120"] with bbox that fits both. + // 48x120 (area 5760) is narrower; 60x120 (area 7200) has more area. + // SmallestArea picks 48x120; NarrowestFirst also picks 48x120. Both pick the same. + // + // Real divergence: AllowedSizes = ["60x120", "72x120"] with bbox 55x100. + // 60x120 has narrower width (60) AND smaller area (7200 vs 8640), so both agree. + // + // To force divergence: AllowedSizes = ["60x96", "48x144"] with bbox 47x95. + // 60x96 area = 5760, 48x144 area = 6912. SmallestArea -> 60x96. + // NarrowestFirst width 48 < 60 -> 48x144. + var bbox = new Box(0, 0, 47, 95); + var options = new PlateSizeOptions + { + AllowedSizes = new[] { "60x96", "48x144" }, + Selection = PlateSizeSelection.NarrowestFirst, + }; + + var result = PlateSizes.Recommend(bbox, options); + + Assert.Equal(48, result.Width); + Assert.Equal(144, result.Length); + } + + [Fact] + public void Recommend_SmallestArea_PicksSmallerAreaOverNarrowerWidth() + { + var bbox = new Box(0, 0, 47, 95); + var options = new PlateSizeOptions + { + AllowedSizes = new[] { "60x96", "48x144" }, + Selection = PlateSizeSelection.SmallestArea, + }; + + var result = PlateSizes.Recommend(bbox, options); + + Assert.Equal(60, result.Width); + Assert.Equal(96, result.Length); + } +} diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 845ea22..f26abcc 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -7,6 +7,7 @@ using OpenNest.Engine.Sequencing; using OpenNest.IO; using OpenNest.Math; using OpenNest.Properties; +using OpenNest.Shapes; using System; using System.ComponentModel; using System.Diagnostics; @@ -453,7 +454,11 @@ namespace OpenNest.Forms public void ResizePlateToFitParts() { - PlateView.Plate.AutoSize(Settings.Default.AutoSizePlateFactor); + var options = new PlateSizeOptions + { + SnapIncrement = Settings.Default.AutoSizePlateFactor, + }; + PlateView.Plate.SnapToStandardSize(options); PlateView.ZoomToPlate(); PlateView.Refresh(); UpdatePlateList(); From 6880dee48936d67ace6c15533e4b82b005f63061 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 22:46:47 -0400 Subject: [PATCH 12/20] fix(splitter): preserve disconnected strips and trim cuts around cutouts Splits that cross an interior cutout previously merged physically disconnected strips into one drawing and drew cut lines through the hole. The region boundary now spans full feature-edge extents (trimmed against cutout polygons) and line entities are Liang-Barsky clipped, so multi-split edges work. Arcs are properly clipped at region boundaries via iterative split-at-intersection so circles that straddle a split contribute to both sides. AssemblePieces groups a region's entities into connected closed loops and nests holes by bbox-pre-check + vertex-in-polygon containment, so one region can emit multiple drawings when a cutout fully spans it. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Splitting/DrawingSplitter.cs | 530 ++-- OpenNest.Tests/OpenNest.Tests.csproj | 3 + .../Splitting/DrawingSplitterTests.cs | 155 + .../Splitting/TestData/split_test.dxf | 2554 +++++++++++++++++ 4 files changed, 3054 insertions(+), 188 deletions(-) create mode 100644 OpenNest.Tests/Splitting/TestData/split_test.dxf diff --git a/OpenNest.Core/Splitting/DrawingSplitter.cs b/OpenNest.Core/Splitting/DrawingSplitter.cs index 99e54eb..0bbd577 100644 --- a/OpenNest.Core/Splitting/DrawingSplitter.cs +++ b/OpenNest.Core/Splitting/DrawingSplitter.cs @@ -32,12 +32,20 @@ public static class DrawingSplitter var regions = BuildClipRegions(sortedLines, bounds); var feature = GetFeature(parameters.Type); + // Polygonize cutouts once. Used for trimming feature edges (so cut lines + // don't travel through a cutout interior) and for hole/containment tests + // in the final component-assembly pass. + var cutoutPolygons = profile.Cutouts + .Select(c => c.ToPolygon()) + .Where(p => p != null) + .ToList(); + var results = new List(); var pieceIndex = 1; foreach (var region in regions) { - var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters); + var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters, cutoutPolygons); if (pieceEntities.Count == 0) continue; @@ -47,9 +55,16 @@ public static class DrawingSplitter allEntities.AddRange(pieceEntities); allEntities.AddRange(cutoutEntities); - var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region); - results.Add(piece); - pieceIndex++; + // A single region may yield multiple physically-disjoint pieces when an + // interior cutout spans across it. Group the region's entities into + // connected closed loops, nest holes by containment, and emit one + // Drawing per outer loop (with its contained holes). + foreach (var pieceOfRegion in AssemblePieces(allEntities)) + { + var piece = BuildPieceDrawing(drawing, pieceOfRegion, pieceIndex, region); + results.Add(piece); + pieceIndex++; + } } return results; @@ -218,100 +233,108 @@ public static class DrawingSplitter /// and stitching in feature edges. No polygon clipping library needed. /// private static List ClipPerimeterToRegion(Shape perimeter, Box region, - List splitLines, ISplitFeature feature, SplitParameters parameters) + List splitLines, ISplitFeature feature, SplitParameters parameters, + List cutoutPolygons) { var boundarySplitLines = GetBoundarySplitLines(region, splitLines); var entities = new List(); - var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>(); foreach (var entity in perimeter.Entities) - { - ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints); - } + ProcessEntity(entity, region, entities); if (entities.Count == 0) return new List(); - InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters); - EnsurePerimeterWinding(entities); + InsertFeatureEdges(entities, region, boundarySplitLines, feature, parameters, cutoutPolygons); + // Winding is handled later in AssemblePieces, once connected components + // are known. At this stage the piece may still be multiple disjoint loops. return entities; } - private static void ProcessEntity(Entity entity, Box region, - List boundarySplitLines, List entities, - List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints) - { - // Find the first boundary split line this entity crosses - SplitLine crossedLine = null; - Vector? intersectionPt = null; - - foreach (var sl in boundarySplitLines) - { - if (SplitLineIntersect.CrossesSplitLine(entity, sl)) - { - var pt = SplitLineIntersect.FindIntersection(entity, sl); - if (pt != null) - { - crossedLine = sl; - intersectionPt = pt; - break; - } - } - } - - if (crossedLine != null) - { - // Entity crosses a split line — split it and keep the half inside the region - var regionSide = RegionSideOf(region, crossedLine); - var startPt = GetStartPoint(entity); - var startSide = SplitLineIntersect.SideOf(startPt, crossedLine); - var startInRegion = startSide == regionSide || startSide == 0; - - SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints); - } - else - { - // Entity doesn't cross any boundary split line — check if it's inside the region - var mid = MidPoint(entity); - if (region.Contains(mid)) - entities.Add(entity); - } - } - - private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion, - SplitLine crossedLine, List entities, - List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints) + private static void ProcessEntity(Entity entity, Box region, List entities) { if (entity is Line line) { - var (first, second) = line.SplitAt(point); - if (startInRegion) - { - if (first != null) entities.Add(first); - splitPoints.Add((point, crossedLine, true)); - } - else - { - splitPoints.Add((point, crossedLine, false)); - if (second != null) entities.Add(second); - } + var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region); + if (clipped == null) return; + if (clipped.Value.Start.DistanceTo(clipped.Value.End) < Math.Tolerance.Epsilon) return; + entities.Add(new Line(clipped.Value.Start, clipped.Value.End)); + return; } - else if (entity is Arc arc) + + if (entity is Arc arc) { - var (first, second) = arc.SplitAt(point); - if (startInRegion) - { - if (first != null) entities.Add(first); - splitPoints.Add((point, crossedLine, true)); - } - else - { - splitPoints.Add((point, crossedLine, false)); - if (second != null) entities.Add(second); - } + foreach (var sub in ClipArcToRegion(arc, region)) + entities.Add(sub); + return; } } + /// + /// Clips an arc against the four edges of a region box. Returns the sub-arcs + /// whose midpoints lie inside the region. Uses line-arc intersection to find + /// split points, then iteratively bisects the arc at each crossing. + /// + private static List ClipArcToRegion(Arc arc, Box region) + { + var edges = new[] + { + new Line(new Vector(region.Left, region.Bottom), new Vector(region.Right, region.Bottom)), + new Line(new Vector(region.Right, region.Bottom), new Vector(region.Right, region.Top)), + new Line(new Vector(region.Right, region.Top), new Vector(region.Left, region.Top)), + new Line(new Vector(region.Left, region.Top), new Vector(region.Left, region.Bottom)) + }; + + var arcs = new List { arc }; + + foreach (var edge in edges) + { + var next = new List(); + foreach (var a in arcs) + { + if (!Intersect.Intersects(a, edge, out var pts) || pts.Count == 0) + { + next.Add(a); + continue; + } + + // Split the arc at each intersection that actually lies on one of + // the working sub-arcs. Prior splits may make some original hits + // moot for the sub-arc that now holds them. + var working = new List { a }; + foreach (var pt in pts) + { + var replaced = new List(); + foreach (var w in working) + { + var onArc = OpenNest.Math.Angle.IsBetweenRad( + w.Center.AngleTo(pt), w.StartAngle, w.EndAngle, w.IsReversed); + if (!onArc) + { + replaced.Add(w); + continue; + } + + var (first, second) = w.SplitAt(pt); + if (first != null && first.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(first); + if (second != null && second.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(second); + } + working = replaced; + } + next.AddRange(working); + } + arcs = next; + } + + var result = new List(); + foreach (var a in arcs) + { + if (region.Contains(a.MidPoint())) + result.Add(a); + } + return result; + } + /// /// Returns split lines whose position matches a boundary edge of the region. /// @@ -365,104 +388,157 @@ public static class DrawingSplitter } /// - /// Groups split points by split line, pairs exits with entries, and generates feature edges. + /// For each boundary split line of the region, generates a feature edge that + /// spans the full region boundary along that split line and trims it against + /// interior cutouts. This produces one (or zero) feature edge per contiguous + /// material interval on the boundary, handling corner regions (one perimeter + /// crossing), spanning cutouts (two holes puncturing the line), and + /// normal mid-part splits uniformly. /// private static void InsertFeatureEdges(List entities, - List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints, Box region, List boundarySplitLines, - ISplitFeature feature, SplitParameters parameters) + ISplitFeature feature, SplitParameters parameters, + List cutoutPolygons) { - // Group split points by their split line - var groups = new Dictionary>(); - foreach (var sp in splitPoints) + foreach (var sl in boundarySplitLines) { - if (!groups.ContainsKey(sp.Line)) - groups[sp.Line] = new List<(Vector, bool)>(); - groups[sp.Line].Add((sp.Point, sp.IsExit)); - } + var isVertical = sl.Axis == CutOffAxis.Vertical; + var extentStart = isVertical ? region.Bottom : region.Left; + var extentEnd = isVertical ? region.Top : region.Right; - foreach (var kvp in groups) - { - var sl = kvp.Key; - var points = kvp.Value; - - // Pair each exit with the next entry - var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList(); - var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList(); - - if (exits.Count == 0 || entries.Count == 0) + if (extentEnd - extentStart < Math.Tolerance.Epsilon) continue; - // For each exit, find the matching entry to form the feature edge span - // Sort exits and entries by their position along the split line - var isVertical = sl.Axis == CutOffAxis.Vertical; - exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList(); - entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList(); + var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters); + var isNegativeSide = RegionSideOf(region, sl) < 0; + var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge; - // Pair them up: each exit with the next entry (or vice versa) - var pairCount = System.Math.Min(exits.Count, entries.Count); - for (var i = 0; i < pairCount; i++) + // Trim any line segments that cross a cutout — cut lines must never + // travel through a hole. + featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons); + + entities.AddRange(featureEdge); + } + } + + /// + /// Subtracts any portions of line entities in that + /// lie inside any of the supplied cutout polygons. Non-line entities (arcs) are + /// passed through unchanged; a tighter fix for arcs in feature edges (weld-gap + /// tabs, spike-groove) can be added later if a test demands it. + /// + private static List TrimFeatureEdgeAgainstCutouts(List featureEdge, List cutoutPolygons) + { + if (cutoutPolygons.Count == 0 || featureEdge.Count == 0) + return featureEdge; + + var result = new List(); + foreach (var entity in featureEdge) + { + if (entity is Line line) + result.AddRange(SubtractCutoutsFromLine(line, cutoutPolygons)); + else + result.Add(entity); + } + return result; + } + + /// + /// Returns the sub-segments of that lie outside every + /// cutout polygon. Handles the common axis-aligned feature-edge case exactly. + /// + private static List SubtractCutoutsFromLine(Line line, List cutoutPolygons) + { + // Collect parameter values t in [0,1] where the line crosses any cutout edge. + var ts = new List { 0.0, 1.0 }; + foreach (var poly in cutoutPolygons) + { + var polyLines = poly.ToLines(); + foreach (var edge in polyLines) { - var exitPt = exits[i]; - var entryPt = entries[i]; - - var extentStart = isVertical - ? System.Math.Min(exitPt.Y, entryPt.Y) - : System.Math.Min(exitPt.X, entryPt.X); - var extentEnd = isVertical - ? System.Math.Max(exitPt.Y, entryPt.Y) - : System.Math.Max(exitPt.X, entryPt.X); - - var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters); - - var isNegativeSide = RegionSideOf(region, sl) < 0; - var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge; - - if (featureEdge.Count > 0) - featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis); - - entities.AddRange(featureEdge); + if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t)) + { + if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon) + ts.Add(t); + } } } - } - private static List AlignFeatureDirection(List featureEdge, Vector start, Vector end, CutOffAxis axis) - { - var featureStart = GetStartPoint(featureEdge[0]); - var featureEnd = GetEndPoint(featureEdge[^1]); - var isVertical = axis == CutOffAxis.Vertical; + ts.Sort(); - var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X; - var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X; - - if (edgeGoesForward != featureGoesForward) + var segments = new List(); + for (var i = 0; i < ts.Count - 1; i++) { - featureEdge = new List(featureEdge); - featureEdge.Reverse(); - foreach (var e in featureEdge) - e.Reverse(); + var t0 = ts[i]; + var t1 = ts[i + 1]; + if (t1 - t0 < Math.Tolerance.Epsilon) continue; + + var tMid = (t0 + t1) * 0.5; + var mid = new Vector( + line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * tMid, + line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * tMid); + + var insideCutout = false; + foreach (var poly in cutoutPolygons) + { + if (poly.ContainsPoint(mid)) + { + insideCutout = true; + break; + } + } + if (insideCutout) continue; + + var p0 = new Vector( + line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t0, + line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t0); + var p1 = new Vector( + line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t1, + line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t1); + + segments.Add(new Line(p0, p1)); } - return featureEdge; + return segments; } - private static void EnsurePerimeterWinding(List entities) + /// + /// Segment-segment intersection. On hit, returns the parameter t along segment AB + /// (0 = a0, 1 = a1) via . + /// + private static bool TryIntersectSegments(Vector a0, Vector a1, Vector b0, Vector b1, out double tOnA) { - var shape = new Shape(); - shape.Entities.AddRange(entities); - var poly = shape.ToPolygon(); - if (poly != null && poly.RotationDirection() != RotationType.CW) - shape.Reverse(); + tOnA = 0; + var rx = a1.X - a0.X; + var ry = a1.Y - a0.Y; + var sx = b1.X - b0.X; + var sy = b1.Y - b0.Y; - entities.Clear(); - entities.AddRange(shape.Entities); + var denom = rx * sy - ry * sx; + if (System.Math.Abs(denom) < Math.Tolerance.Epsilon) + return false; + + var dx = b0.X - a0.X; + var dy = b0.Y - a0.Y; + var t = (dx * sy - dy * sx) / denom; + var u = (dx * ry - dy * rx) / denom; + + if (t < -Math.Tolerance.Epsilon || t > 1 + Math.Tolerance.Epsilon) return false; + if (u < -Math.Tolerance.Epsilon || u > 1 + Math.Tolerance.Epsilon) return false; + + tOnA = t; + return true; } private static bool IsCutoutInRegion(Shape cutout, Box region) { if (cutout.Entities.Count == 0) return false; - var pt = GetStartPoint(cutout.Entities[0]); - return region.Contains(pt); + var bb = cutout.BoundingBox; + // Fully contained iff the cutout's bounding box fits inside the region. + return bb.Left >= region.Left - Math.Tolerance.Epsilon + && bb.Right <= region.Right + Math.Tolerance.Epsilon + && bb.Bottom >= region.Bottom - Math.Tolerance.Epsilon + && bb.Top <= region.Top + Math.Tolerance.Epsilon; } private static bool DoesCutoutCrossSplitLine(Shape cutout, List splitLines) @@ -479,57 +555,135 @@ public static class DrawingSplitter } /// - /// Clip a cutout shape to a region by walking entities, splitting at split line - /// intersections, keeping portions inside the region, and closing gaps with - /// straight lines. No polygon clipping library needed. + /// Clip a cutout shape to a region by walking entities and splitting at split-line + /// crossings. Only returns the cutout-edge fragments that lie inside the region — + /// it deliberately does NOT emit synthetic closing lines at the region boundary. + /// + /// Rationale: a closing line on the region boundary would overlap the split-line + /// feature edge and reintroduce a cut through the cutout interior. The feature + /// edge (trimmed against cutouts in ) and these + /// cutout fragments are stitched together later by + /// using endpoint connectivity, which produces the correct closed loops — one + /// loop per physically-connected strip of material. /// private static List ClipCutoutToRegion(Shape cutout, Box region, List splitLines) { - var boundarySplitLines = GetBoundarySplitLines(region, splitLines); var entities = new List(); - var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>(); - foreach (var entity in cutout.Entities) + ProcessEntity(entity, region, entities); + return entities; + } + + /// + /// Groups a region's entities into closed components and nests holes inside + /// outer loops by point-in-polygon containment. Returns one entity list per + /// output — outer loop first, then its contained holes. + /// Each outer loop is normalized to CW winding and each hole to CCW. + /// + private static List> AssemblePieces(List entities) + { + var pieces = new List>(); + if (entities.Count == 0) return pieces; + + var shapes = ShapeBuilder.GetShapes(entities); + if (shapes.Count == 0) return pieces; + + // Polygonize every shape once so we can run containment tests. + var polygons = new List(shapes.Count); + foreach (var s in shapes) + polygons.Add(s.ToPolygon()); + + // Classify each shape as outer or hole using nesting by containment. + // Shape A is contained in shape B iff A's bounding box is strictly inside + // B's bounding box AND a representative vertex of A lies inside B's polygon. + // The bbox pre-check avoids the ambiguity of bbox-center tests when two + // shapes share a center (e.g., an outer half and a centered cutout). + var isHole = new bool[shapes.Count]; + for (var i = 0; i < shapes.Count; i++) { - ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints); + var bbA = shapes[i].BoundingBox; + var repA = FirstVertexOf(shapes[i]); + + for (var j = 0; j < shapes.Count; j++) + { + if (i == j) continue; + if (polygons[j] == null) continue; + if (polygons[j].Vertices.Count < 3) continue; + + var bbB = shapes[j].BoundingBox; + if (!BoxContainsBox(bbB, bbA)) continue; + if (!polygons[j].ContainsPoint(repA)) continue; + + isHole[i] = true; + break; + } } - if (entities.Count == 0) - return new List(); - - // Close gaps with straight lines (connect exit→entry pairs) - var groups = new Dictionary>(); - foreach (var sp in splitPoints) + // For each outer, attach the holes that fall inside it. + for (var i = 0; i < shapes.Count; i++) { - if (!groups.ContainsKey(sp.Line)) - groups[sp.Line] = new List<(Vector, bool)>(); - groups[sp.Line].Add((sp.Point, sp.IsExit)); + if (isHole[i]) continue; + + var outer = shapes[i]; + var outerPoly = polygons[i]; + + // Enforce perimeter winding = CW. + if (outerPoly != null && outerPoly.Vertices.Count >= 3 + && outerPoly.RotationDirection() != RotationType.CW) + outer.Reverse(); + + var piece = new List(); + piece.AddRange(outer.Entities); + + for (var j = 0; j < shapes.Count; j++) + { + if (!isHole[j]) continue; + if (polygons[i] == null || polygons[i].Vertices.Count < 3) continue; + + var bbJ = shapes[j].BoundingBox; + if (!BoxContainsBox(shapes[i].BoundingBox, bbJ)) continue; + + var rep = FirstVertexOf(shapes[j]); + if (!polygons[i].ContainsPoint(rep)) continue; + + var hole = shapes[j]; + var holePoly = polygons[j]; + if (holePoly != null && holePoly.Vertices.Count >= 3 + && holePoly.RotationDirection() != RotationType.CCW) + hole.Reverse(); + + piece.AddRange(hole.Entities); + } + + pieces.Add(piece); } - foreach (var kvp in groups) - { - var sl = kvp.Key; - var points = kvp.Value; - var isVertical = sl.Axis == CutOffAxis.Vertical; + return pieces; + } - var exits = points.Where(p => p.IsExit).Select(p => p.Point) - .OrderBy(p => isVertical ? p.Y : p.X).ToList(); - var entries = points.Where(p => !p.IsExit).Select(p => p.Point) - .OrderBy(p => isVertical ? p.Y : p.X).ToList(); + /// + /// Returns the first vertex of a shape (start point of its first entity). Used as + /// a representative for containment testing: if bbox pre-check says the whole + /// shape is inside another, testing one vertex is sufficient to confirm. + /// + private static Vector FirstVertexOf(Shape shape) + { + if (shape.Entities.Count == 0) + return new Vector(0, 0); + return GetStartPoint(shape.Entities[0]); + } - var pairCount = System.Math.Min(exits.Count, entries.Count); - for (var i = 0; i < pairCount; i++) - entities.Add(new Line(exits[i], entries[i])); - } - - // Ensure CCW winding for cutouts - var shape = new Shape(); - shape.Entities.AddRange(entities); - var poly = shape.ToPolygon(); - if (poly != null && poly.RotationDirection() != RotationType.CCW) - shape.Reverse(); - - return shape.Entities; + /// + /// True iff box is entirely inside box + /// (tolerant comparison). + /// + private static bool BoxContainsBox(Box outer, Box inner) + { + var eps = Math.Tolerance.Epsilon; + return inner.Left >= outer.Left - eps + && inner.Right <= outer.Right + eps + && inner.Bottom >= outer.Bottom - eps + && inner.Top <= outer.Top + eps; } private static Vector GetStartPoint(Entity entity) diff --git a/OpenNest.Tests/OpenNest.Tests.csproj b/OpenNest.Tests/OpenNest.Tests.csproj index bc484e8..be3afca 100644 --- a/OpenNest.Tests/OpenNest.Tests.csproj +++ b/OpenNest.Tests/OpenNest.Tests.csproj @@ -34,6 +34,9 @@ PreserveNewest + + PreserveNewest + diff --git a/OpenNest.Tests/Splitting/DrawingSplitterTests.cs b/OpenNest.Tests/Splitting/DrawingSplitterTests.cs index 45ea0d8..534e7b2 100644 --- a/OpenNest.Tests/Splitting/DrawingSplitterTests.cs +++ b/OpenNest.Tests/Splitting/DrawingSplitterTests.cs @@ -384,6 +384,161 @@ public class DrawingSplitterTests } } + [Fact] + public void Split_RectangleWithSpanningSlot_ProducesDisconnectedStrips() + { + // 255x55 outer rectangle with a 235x35 interior slot centered at (10,10)-(245,45). + // 4 vertical splits at x = 55, 110, 165, 220. + // + // Expected: regions R2/R3/R4 are entirely "over" the slot horizontally, so the + // surviving material in each is two physically disjoint strips (upper + lower). + // R1 and R5 each have a solid edge that connects the top and bottom strips, so + // they remain single (notched) pieces. + // + // Total output drawings: 1 (R1) + 2 (R2) + 2 (R3) + 2 (R4) + 1 (R5) = 8. + var outerEntities = new List + { + new Line(new Vector(0, 0), new Vector(255, 0)), + new Line(new Vector(255, 0), new Vector(255, 55)), + new Line(new Vector(255, 55), new Vector(0, 55)), + new Line(new Vector(0, 55), new Vector(0, 0)) + }; + var slotEntities = new List + { + new Line(new Vector(10, 10), new Vector(245, 10)), + new Line(new Vector(245, 10), new Vector(245, 45)), + new Line(new Vector(245, 45), new Vector(10, 45)), + new Line(new Vector(10, 45), new Vector(10, 10)) + }; + var allEntities = new List(); + allEntities.AddRange(outerEntities); + allEntities.AddRange(slotEntities); + + var drawing = new Drawing("SLOT", ConvertGeometry.ToProgram(allEntities)); + var originalArea = drawing.Area; + + var splitLines = new List + { + new SplitLine(55.0, CutOffAxis.Vertical), + new SplitLine(110.0, CutOffAxis.Vertical), + new SplitLine(165.0, CutOffAxis.Vertical), + new SplitLine(220.0, CutOffAxis.Vertical) + }; + + var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters { Type = SplitType.Straight }); + + // R1 (0..55) → 1 notched piece, height 55 + // R2 (55..110) → upper strip + lower strip, each height 10 + // R3 (110..165)→ upper strip + lower strip, each height 10 + // R4 (165..220)→ upper strip + lower strip, each height 10 + // R5 (220..255)→ 1 notched piece, height 55 + Assert.Equal(8, results.Count); + + // Area preservation: sum of all output areas equals (outer − slot). + var totalArea = results.Sum(d => d.Area); + Assert.Equal(originalArea, totalArea, 1); + + // Box.Length = X-extent, Box.Width = Y-extent. + // Exactly 6 strips (Y-extent ~10mm) from the three middle regions, and + // exactly 2 notched pieces (Y-extent 55mm) from R1 and R5. + var strips = results + .Where(d => System.Math.Abs(d.Program.BoundingBox().Width - 10.0) < 0.5) + .ToList(); + var notched = results + .Where(d => System.Math.Abs(d.Program.BoundingBox().Width - 55.0) < 0.5) + .ToList(); + + Assert.Equal(6, strips.Count); + Assert.Equal(2, notched.Count); + + // Each piece should form a closed perimeter (no dangling edges, no gaps). + foreach (var piece in results) + { + var entities = ConvertProgram.ToGeometry(piece.Program) + .Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + + Assert.True(entities.Count >= 3, $"{piece.Name} must have at least 3 edges"); + + for (var i = 0; i < entities.Count; i++) + { + var end = GetEndPoint(entities[i]); + var nextStart = GetStartPoint(entities[(i + 1) % entities.Count]); + var gap = end.DistanceTo(nextStart); + Assert.True(gap < 0.01, + $"{piece.Name} gap of {gap:F4} between edge {i} end and edge {(i + 1) % entities.Count} start"); + } + } + } + + [Fact] + public void Split_DxfFile_WithSpanningSlot_HasNoCutLinesThroughCutout() + { + // Real DXF regression: 255x55 plate with a centered slot cutout, split into + // five columns. Exercises the same path as the synthetic + // Split_RectangleWithSpanningSlot_ProducesDisconnectedStrips test but through + // the full DXF import pipeline. + var path = Path.Combine(AppContext.BaseDirectory, "Splitting", "TestData", "split_test.dxf"); + Assert.True(File.Exists(path), $"Test DXF not found: {path}"); + + var imported = OpenNest.IO.Dxf.Import(path); + var profile = new OpenNest.Geometry.ShapeProfile(imported.Entities); + + // Normalize to origin so the split line positions are predictable. + var bb = profile.Perimeter.BoundingBox; + var offsetX = -bb.X; + var offsetY = -bb.Y; + foreach (var e in profile.Perimeter.Entities) e.Offset(offsetX, offsetY); + foreach (var cutout in profile.Cutouts) + foreach (var e in cutout.Entities) e.Offset(offsetX, offsetY); + + var allEntities = new List(); + allEntities.AddRange(profile.Perimeter.Entities); + foreach (var cutout in profile.Cutouts) allEntities.AddRange(cutout.Entities); + + var drawing = new Drawing("SPLITTEST", ConvertGeometry.ToProgram(allEntities)); + var originalArea = drawing.Area; + + // Part is ~255x55 with an interior slot. Split into 5 columns (55mm each). + var splitLines = new List + { + new SplitLine(55.0, CutOffAxis.Vertical), + new SplitLine(110.0, CutOffAxis.Vertical), + new SplitLine(165.0, CutOffAxis.Vertical), + new SplitLine(220.0, CutOffAxis.Vertical) + }; + + var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters { Type = SplitType.Straight }); + + // Area must be preserved within tolerance (floating-point coords in the DXF). + var totalArea = results.Sum(d => d.Area); + Assert.Equal(originalArea, totalArea, 0); + + // At least one region must yield more than one physical strip — that's the + // whole point of the fix: a cutout that spans a region disconnects it. + Assert.True(results.Count > splitLines.Count + 1, + $"Expected more than {splitLines.Count + 1} pieces (some regions split into strips), got {results.Count}"); + + // Every output drawing must resolve into fully-closed shapes (outer loop + // and any hole loops), with no dangling geometry. A piece that contains + // a cutout will have its entities span more than one connected loop. + foreach (var piece in results) + { + var entities = ConvertProgram.ToGeometry(piece.Program) + .Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + + Assert.True(entities.Count >= 3, $"{piece.Name} has only {entities.Count} entities"); + + var shapes = OpenNest.Geometry.ShapeBuilder.GetShapes(entities); + Assert.NotEmpty(shapes); + + foreach (var shape in shapes) + { + Assert.True(shape.IsClosed(), + $"{piece.Name} contains an open chain of {shape.Entities.Count} entities"); + } + } + } + private static Vector GetStartPoint(Entity entity) { return entity switch diff --git a/OpenNest.Tests/Splitting/TestData/split_test.dxf b/OpenNest.Tests/Splitting/TestData/split_test.dxf new file mode 100644 index 0000000..2618e00 --- /dev/null +++ b/OpenNest.Tests/Splitting/TestData/split_test.dxf @@ -0,0 +1,2554 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1018 + 9 +$ACADMAINTVER + 70 + 2 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +19.91850545569161 + 20 +5.515998377183137 + 30 +-0.0000000349181948 + 9 +$EXTMAX + 10 +274.9408108934591 + 20 +60.52360502754011 + 30 +0.0000000814935747 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +12.0 + 20 +9.0 + 9 +$ORTHOMODE + 70 + 0 + 9 +$REGENMODE + 70 + 1 + 9 +$FILLMODE + 70 + 1 + 9 +$QTEXTMODE + 70 + 0 + 9 +$MIRRTEXT + 70 + 0 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 + 1 + 9 +$TEXTSIZE + 40 +0.2 + 9 +$TRACEWID + 40 +0.05 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 + 256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 + 0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +0.18 + 9 +$DIMEXO + 40 +0.0625 + 9 +$DIMDLI + 40 +0.38 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +0.18 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +0.18 + 9 +$DIMCEN + 40 +0.09 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 + 0 + 9 +$DIMLIM + 70 + 0 + 9 +$DIMTIH + 70 + 1 + 9 +$DIMTOH + 70 + 1 + 9 +$DIMSE1 + 70 + 0 + 9 +$DIMSE2 + 70 + 0 + 9 +$DIMTAD + 70 + 0 + 9 +$DIMZIN + 70 + 0 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 + 1 + 9 +$DIMSHO + 70 + 1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 + 0 + 9 +$DIMALTD + 70 + 2 + 9 +$DIMALTF + 40 +25.4 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 + 0 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 + 0 + 9 +$DIMSOXD + 70 + 0 + 9 +$DIMSAH + 70 + 0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +Standard + 9 +$DIMCLRD + 70 + 0 + 9 +$DIMCLRE + 70 + 0 + 9 +$DIMCLRT + 70 + 0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.09 + 9 +$DIMJUST + 70 + 0 + 9 +$DIMSD1 + 70 + 0 + 9 +$DIMSD2 + 70 + 0 + 9 +$DIMTOLJ + 70 + 1 + 9 +$DIMTZIN + 70 + 0 + 9 +$DIMALTZ + 70 + 0 + 9 +$DIMALTTZ + 70 + 0 + 9 +$DIMUPT + 70 + 0 + 9 +$DIMDEC + 70 + 4 + 9 +$DIMTDEC + 70 + 4 + 9 +$DIMALTU + 70 + 2 + 9 +$DIMALTTD + 70 + 2 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 + 0 + 9 +$DIMADEC + 70 + 0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 + 0 + 9 +$DIMDSEP + 70 + 46 + 9 +$DIMATFIT + 70 + 3 + 9 +$DIMFRAC + 70 + 0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 + 2 + 9 +$DIMLWD + 70 + -2 + 9 +$DIMLWE + 70 + -2 + 9 +$DIMTMOVE + 70 + 0 + 9 +$LUNITS + 70 + 2 + 9 +$LUPREC + 70 + 4 + 9 +$SKETCHINC + 40 +0.1 + 9 +$FILLETRAD + 40 +0.0 + 9 +$AUNITS + 70 + 0 + 9 +$AUPREC + 70 + 0 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 + 0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 + 0 + 9 +$TDCREATE + 40 +2461141.845430382 + 9 +$TDUCREATE + 40 +2461142.012097049 + 9 +$TDUPDATE + 40 +2461141.879244201 + 9 +$TDUUPDATE + 40 +2461142.045910868 + 9 +$TDINDWG + 40 +0.0024387384 + 9 +$TDUSRTIMER + 40 +0.0024382986 + 9 +$USRTIMER + 70 + 1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 + 0 + 9 +$PDMODE + 70 + 0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 + 0 + 9 +$SPLINETYPE + 70 + 6 + 9 +$SPLINESEGS + 70 + 8 + 9 +$HANDSEED + 5 +A2 + 9 +$SURFTAB1 + 70 + 6 + 9 +$SURFTAB2 + 70 + 6 + 9 +$SURFTYPE + 70 + 6 + 9 +$SURFU + 70 + 6 + 9 +$SURFV + 70 + 6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 + 0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 + 0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 + 0 + 9 +$USERI2 + 70 + 0 + 9 +$USERI3 + 70 + 0 + 9 +$USERI4 + 70 + 0 + 9 +$USERI5 + 70 + 0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 + 1 + 9 +$SHADEDGE + 70 + 3 + 9 +$SHADEDIF + 70 + 70 + 9 +$TILEMODE + 70 + 1 + 9 +$MAXACTVP + 70 + 64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 + 0 + 9 +$PEXTMIN + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PEXTMAX + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +12.0 + 20 +9.0 + 9 +$UNITMODE + 70 + 0 + 9 +$VISRETAIN + 70 + 1 + 9 +$PLINEGEN + 70 + 0 + 9 +$PSLTSCALE + 70 + 1 + 9 +$TREEDEPTH + 70 + 3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 + 0 + 9 +$CMLSCALE + 40 +1.0 + 9 +$PROXYGRAPHICS + 70 + 1 + 9 +$MEASUREMENT + 70 + 0 + 9 +$CELWEIGHT +370 + -1 + 9 +$ENDCAPS +280 + 0 + 9 +$JOINSTYLE +280 + 0 + 9 +$LWDISPLAY +290 + 0 + 9 +$INSUNITS + 70 + 1 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 + 1 + 9 +$CEPSNTYPE +380 + 0 + 9 +$PSTYLEMODE +290 + 1 + 9 +$FINGERPRINTGUID + 2 +{FDEAD576-A652-11D2-9A35-0060089B3A3F} + 9 +$VERSIONGUID + 2 +{43BEA035-DE0A-47E5-AE2D-CFCAFBC579EF} + 9 +$EXTNAMES +290 + 1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 + 0 + 9 +$SORTENTS +280 + 127 + 9 +$INDEXCTL +280 + 0 + 9 +$HIDETEXT +280 + 1 + 9 +$XCLIPFRAME +290 + 0 + 9 +$HALOGAP +280 + 0 + 9 +$OBSCOLOR + 70 + 257 + 9 +$OBSLTYPE +280 + 0 + 9 +$INTERSECTIONDISPLAY +280 + 0 + 9 +$INTERSECTIONCOLOR + 70 + 257 + 9 +$DIMASSOC +280 + 2 + 9 +$PROJECTNAME + 1 + + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 + 0 + 91 + 1 +280 + 0 +281 + 0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 + 0 + 91 + 2 +280 + 0 +281 + 0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 + 2 + 0 +VPORT + 5 +A1 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 + 0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +147.4296581745753 + 22 +33.01980170236163 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +121.7516279380305 + 41 +2.099662162162162 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 + 0 + 72 + 1000 + 73 + 1 + 74 + 3 + 75 + 0 + 76 + 0 + 77 + 0 + 78 + 0 +281 + 0 + 65 + 1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 + 0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 + 1 + 0 +LTYPE + 5 +14 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 + 0 + 3 + + 72 + 65 + 73 + 0 + 40 +0.0 + 0 +LTYPE + 5 +15 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 + 0 + 3 + + 72 + 65 + 73 + 0 + 40 +0.0 + 0 +LTYPE + 5 +16 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 + 0 + 3 +Solid line + 72 + 65 + 73 + 0 + 40 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 + 1 + 0 +LAYER + 5 +10 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 + 0 + 62 + 7 + 6 +Continuous +370 + -3 +390 +F + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 + 1 + 0 +STYLE + 5 +11 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 + 0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 + 0 + 42 +0.2 + 3 +txt + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 + 0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 + 0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 + 1 + 0 +APPID + 5 +12 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 + 0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +A +330 +0 +100 +AcDbSymbolTable + 70 + 1 +100 +AcDbDimStyleTable + 71 + 0 + 0 +DIMSTYLE +105 +27 +330 +A +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 + 0 +340 +11 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 + 1 + 0 +BLOCK_RECORD + 5 +1F +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +22 + 0 +BLOCK_RECORD + 5 +58 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +59 + 0 +BLOCK_RECORD + 5 +5D +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space0 +340 +5E + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +20 +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 + 0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +21 +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +5A +330 +58 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 + 0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +5B +330 +58 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +5F +330 +5D +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space0 + 70 + 0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space0 + 1 + + 0 +ENDBLK + 5 +60 +330 +5D +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +89 +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +19.92402932605608 + 20 +5.519910847131079 + 30 +0.0 + 11 +19.92402932605608 + 21 +60.51991084713108 + 31 +0.0 + 0 +LINE + 5 +8A +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +19.92402932605608 + 20 +60.51991084713108 + 30 +0.0 + 11 +274.9240293260561 + 21 +60.51991084713108 + 31 +0.0 + 0 +LINE + 5 +8B +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +19.92402932605608 + 20 +5.519910847131079 + 30 +0.0 + 11 +274.924029326056 + 21 +5.519910847131079 + 31 +0.0 + 0 +LINE + 5 +8C +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +274.9240293260561 + 20 +60.51991084713108 + 30 +0.0 + 11 +274.924029326056 + 21 +5.519910847131079 + 31 +0.0 + 0 +LINE + 5 +8D +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +100.1372327616525 + 20 +50.51991084713108 + 30 +0.0 + 11 +264.9240293260561 + 21 +50.51991084713108 + 31 +0.0 + 0 +LINE + 5 +8F +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +100.1372327616525 + 20 +15.51991084713107 + 30 +0.0 + 11 +264.924029326056 + 21 +15.51991084713108 + 31 +0.0 + 0 +LINE + 5 +90 +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +264.9240293260561 + 20 +50.51991084713108 + 30 +0.0 + 11 +264.924029326056 + 21 +15.51991084713108 + 31 +0.0 + 0 +CIRCLE + 5 +98 +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbCircle + 10 +78.92402932605609 + 20 +33.01991084713108 + 30 +0.0 + 40 +17.5 + 0 +ARC + 5 +9A +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbCircle + 10 +78.92402932605609 + 20 +33.01991084713108 + 30 +0.0 + 40 +27.5 +100 +AcDbArc + 50 +320.4788036413579 + 51 +39.52119635864218 + 0 +ARC + 5 +9B +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbCircle + 10 +78.92402932605609 + 20 +33.01991084713108 + 30 +0.0 + 40 +27.5 +100 +AcDbArc + 50 +140.4788036413578 + 51 +219.5211963586422 + 0 +LINE + 5 +9C +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +57.71082589045966 + 20 +15.51991084713107 + 30 +0.0 + 11 +29.92402932605608 + 21 +15.51991084713108 + 31 +0.0 + 0 +LINE + 5 +9D +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +57.71082589045966 + 20 +50.51991084713108 + 30 +0.0 + 11 +29.92402932605609 + 21 +50.51991084713108 + 31 +0.0 + 0 +LINE + 5 +9E +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +29.92402932605608 + 20 +15.51991084713108 + 30 +0.0 + 11 +29.92402932605608 + 21 +50.51991084713109 + 31 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +C +330 +0 +100 +AcDbDictionary +281 + 1 + 3 +ACAD_COLOR +350 +73 + 3 +ACAD_GROUP +350 +D + 3 +ACAD_LAYOUT +350 +1A + 3 +ACAD_MATERIAL +350 +72 + 3 +ACAD_MLINESTYLE +350 +17 + 3 +ACAD_PLOTSETTINGS +350 +19 + 3 +ACAD_PLOTSTYLENAME +350 +E + 3 +AcDbVariableDictionary +350 +66 + 0 +DICTIONARY + 5 +73 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 0 +DICTIONARY + 5 +D +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 0 +DICTIONARY + 5 +1A +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Layout1 +350 +59 + 3 +Layout2 +350 +5E + 3 +Model +350 +22 + 0 +DICTIONARY + 5 +72 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 0 +DICTIONARY + 5 +17 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Standard +350 +18 + 0 +DICTIONARY + 5 +19 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 0 +ACDBDICTIONARYWDFLT + 5 +E +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Normal +350 +F +100 +AcDbDictionaryWithDefault +340 +F + 0 +DICTIONARY + 5 +66 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +DIMASSOC +350 +67 + 3 +HIDETEXT +350 +6B + 0 +LAYOUT + 5 +59 +102 +{ACAD_REACTORS +330 +1A +102 +} +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +None + 4 + + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 + 688 + 72 + 0 + 73 + 0 + 74 + 5 + 7 + + 75 + 16 +147 +1.0 + 76 + 0 + 77 + 2 + 78 + 300 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 + 1 + 71 + 1 + 10 +0.0 + 20 +0.0 + 11 +12.0 + 21 +9.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +0.0 + 24 +0.0 + 34 +0.0 + 15 +0.0 + 25 +0.0 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 + 0 +330 +58 + 0 +LAYOUT + 5 +5E +102 +{ACAD_REACTORS +330 +1A +102 +} +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +None + 4 + + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +0.0 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 + 688 + 72 + 0 + 73 + 0 + 74 + 5 + 7 + + 75 + 16 +147 +1.0 + 76 + 0 + 77 + 2 + 78 + 300 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout2 + 70 + 1 + 71 + 2 + 10 +0.0 + 20 +0.0 + 11 +12.0 + 21 +9.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +0.0 + 24 +0.0 + 34 +0.0 + 15 +0.0 + 25 +0.0 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 + 0 +330 +5D + 0 +LAYOUT + 5 +22 +102 +{ACAD_REACTORS +330 +1A +102 +} +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +none_device + 4 +ANSI_A_(8.50_x_11.00_Inches) + 6 + + 40 +6.349999904632567 + 41 +19.04999923706055 + 42 +6.350006103515625 + 43 +19.04998779296875 + 44 +215.8999938964844 + 45 +279.3999938964844 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +2.584895464708373 + 70 + 11952 + 72 + 0 + 73 + 1 + 74 + 0 + 7 + + 75 + 0 +147 +0.3868628397755418 + 76 + 0 + 77 + 2 + 78 + 300 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 + 1 + 71 + 0 + 10 +0.0 + 20 +0.0 + 11 +12.0 + 21 +9.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +0.0 + 24 +0.0 + 34 +0.0 + 15 +0.0 + 25 +0.0 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 + 0 +330 +1F + 0 +MLINESTYLE + 5 +18 +102 +{ACAD_REACTORS +330 +17 +102 +} +330 +17 +100 +AcDbMlineStyle + 2 +STANDARD + 70 + 0 + 3 + + 62 + 256 + 51 +90.0 + 52 +90.0 + 71 + 2 + 49 +0.5 + 62 + 256 + 6 +BYLAYER + 49 +-0.5 + 62 + 256 + 6 +BYLAYER + 0 +ACDBPLACEHOLDER + 5 +F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E + 0 +DICTIONARYVAR + 5 +67 +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +2 + 0 +DICTIONARYVAR + 5 +6B +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +1 + 0 +ENDSEC + 0 +EOF From 3e96c62f3314b442fd4bfee296047c8d9fe4d0f4 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 10 Apr 2026 22:55:11 -0400 Subject: [PATCH 13/20] docs(readme): reformat features as tables and document cutout-aware splitter Feature list becomes grouped tables (Import/Export, Nesting, Plate Operations, CNC Output). Nest file format section expands to cover the newer entities/programs/subs layout. Drawing Splitting section gains a paragraph explaining cutout-aware clipping: Liang-Barsky line clipping, arc-vs-region intersection, and connected-component detection that emits one drawing per physically-disconnected strip. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 72 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8dae5cf..5288adc 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,43 @@ OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and ## Features -- **DXF/DWG Import & Export** — Load part drawings from DXF or DWG files and export completed nest layouts as DXF -- **Multiple Fill Strategies** — Grid-based linear fill, interlocking pair fill, rectangle bin packing, extents-based tiling, and more via a pluggable strategy system -- **Best-Fit Pair Nesting** — NFP-based (No Fit Polygon) pair evaluation finds tight-fitting interlocking orientations between parts -- **GPU Acceleration** — Optional ILGPU-based bitmap overlap detection for faster best-fit evaluation -- **Part Rotation** — Automatically tries different rotation angles to find better fits, with optional ML-based angle prediction (ONNX) -- **Gravity Compaction** — After placing parts, pushes them together using polygon-based directional distance to close gaps between irregular shapes -- **Multi-Plate Support** — Work with multiple plates of different sizes and materials in a single nest -- **Sheet Cut-Offs** — Automatically cut the plate to size after nesting, with geometry-aware clearance that avoids placed parts -- **Drawing Splitting** — Split oversized parts into pieces that fit your plate, with straight cuts, weld-gap tabs, or interlocking spike-groove joints -- **BOM Import** — Read bills of materials from Excel spreadsheets to batch-import part lists with quantities -- **Bend Line Detection** — Import bend lines from DXF files with pluggable detectors (SolidWorks flat pattern support built in) -- **Lead-In/Lead-Out & Tabs** — Configurable approach paths, exit paths, and holding tabs for CNC cutting, with snap-to-endpoint/midpoint placement -- **Contour & Program Editing** — Inline G-code editor with contour reordering, direction arrows, and cut direction reversal -- **G-code Output** — Post-process nested layouts to G-code via plugin post-processors -- **User-Defined Variables** — Define named variables in G-code (`diameter = 0.3`) referenced with `$name` syntax; Cincinnati post emits numbered machine variables (`#200`) so operators can adjust values at the control -- **Built-in Shapes** — 12 parametric shapes (circles, rectangles, L-shapes, T-shapes, flanges, etc.) for quick testing or simple parts -- **Interactive Editing** — Zoom, pan, select, clone, push, and manually arrange parts on the plate view -- **Pluggable Engine Architecture** — Swap between built-in nesting engines or load custom engines from plugin DLLs +### Import & Export + +| Feature | Description | +|---------|-------------| +| **DXF/DWG Import** | Load part drawings from AutoCAD DXF or DWG files via ACadSharp | +| **DXF Export** | Export completed nest layouts back to DXF for downstream tools | +| **BOM Import** | Batch-import part lists with quantities from Excel spreadsheets | +| **Bend Line Detection** | Import bend lines from DXF via pluggable detectors (SolidWorks flat pattern built in) | +| **Built-in Shapes** | 12 parametric shapes (circles, rectangles, L/T/flange, etc.) for quick parts | + +### Nesting + +| Feature | Description | +|---------|-------------| +| **Pluggable Engines** | Default multi-phase, Vertical Remnant, Horizontal Remnant, plus custom plugin DLLs | +| **Fill Strategies** | Linear grid, interlocking pairs, rectangle best-fit, and extents-based tiling | +| **Best-Fit Pair Nesting** | NFP-based pair evaluation finds tight interlocking orientations between parts | +| **Gravity Compaction** | Polygon-based directional push to close gaps after filling | +| **Part Rotation** | Automatic angle sweep to find better fits across allowed orientations | +| **Multi-Plate Support** | Manage multiple plates of different sizes and materials in one nest | + +### Plate Operations + +| Feature | Description | +|---------|-------------| +| **Sheet Cut-Offs** | Auto-generated trim cuts with geometry-aware clearance around placed parts | +| **Drawing Splitting** | Split oversized parts with straight cuts, weld-gap tabs, or spike-groove joints | +| **Interactive Editing** | Zoom, pan, select, clone, rotate, push, and manually arrange parts | + +### CNC Output + +| Feature | Description | +|---------|-------------| +| **Lead-Ins, Lead-Outs & Tabs** | Configurable approach/exit paths and holding tabs with snap placement | +| **Contour & Program Editing** | Inline G-code editor with contour reordering and cut-direction reversal | +| **User-Defined Variables** | Named G-code variables (`$name`) emitted as machine variables (`#200+`) at post time | +| **Post-Processors** | Plugin-based G-code generation; Cincinnati CL-707/800/900/940/CLX included | ![OpenNest - 44 parts nested on a 60x120 plate](screenshots/screenshot-nest-2.png) @@ -172,6 +191,8 @@ Oversized parts that don't fit on a single plate can be split into smaller piece The split system supports fit-to-plate (auto-calculates split lines) and split-by-count modes, with an interactive UI for adjusting split positions and feature parameters. +**Cutout-aware clipping.** Split lines are trimmed against interior cutouts so cut paths never travel through a hole. Lines are Liang-Barsky clipped at region boundaries and arcs/circles are iteratively split at their intersections with the region box, so a cutout that straddles a split correctly contributes material to both sides. When a cutout fully spans the region between two splits, the material breaks into physically disconnected strips — the splitter detects the connected components via endpoint connectivity, nests any remaining holes inside their outer loops by bounding-box and point-in-polygon containment, and emits one drawing per strip. + ## Post-Processors Post-processors convert nested layouts into machine-specific G-code. They are loaded as plugin DLLs from the `Posts/` directory at runtime. @@ -212,16 +233,11 @@ Custom post-processors implement the `IPostProcessor` interface and are auto-dis Nest files (`.nest`) are ZIP archives containing: -- `nest.json` — JSON metadata: nest info, plate defaults, drawings (with bend data), and plates (with parts and cut-offs) -- `programs/program-N` — G-code text for each drawing's cut program (may include variable definitions and `$name` references) -- `bestfits/bestfit-N` — Cached best-fit pair evaluation results (optional) - -## Roadmap - -- **NFP-based auto-nesting** — Simulated annealing optimizer and NFP placement exist in the engine but aren't exposed as a selectable engine yet -- **Geometry simplifier** — Replace consecutive small line segments with fitted arcs to reduce program size and improve nesting performance -- **Shape library UI** — 12 built-in parametric shapes exist in code; needs a browsable library UI for quick access -- **Additional post-processors** — Plugin interface is in place; more machine-specific post-processors planned +- `nest.json` — JSON metadata: nest info (name, customer, units, material, thickness, assist gas, salvage rate), plate defaults, plate options (alternative sizes with cost), drawings (with bend lines, material, source path, rotation constraints), and plates (size, quadrant, grain angle, parts with manual lead-in flags, cut-offs) +- `programs/program-N` — G-code text for drawing N's cut program (may include variable definitions and `$name` references) +- `programs/program-N-subs` — Sub-program definitions for drawing N (M98/G65-callable blocks for repeated features like holes) +- `entities/entities-N` — Original source entities for drawing N (preserved from DXF import with per-entity suppression state for round-trip editing) +- `bestfits/bestfit-N` — Cached best-fit pair evaluation results for drawing N, keyed by plate size and spacing (optional) ## Status From 29c28728192f1671e087cee0f14c780b22a2208e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 12 Apr 2026 21:35:13 -0400 Subject: [PATCH 14/20] fix(geometry): add Entity.Clone() and stop NormalizeEntities from mutating originals ShapeProfile.NormalizeEntities called Shape.Reverse() which flipped arc directions on the original entity objects shared with the CAD view. Switching to the Program tab and back would leave arcs reversed. Clone entities before normalizing so the originals stay untouched. Adds abstract Entity.Clone() with implementations on Line, Arc, Circle, Polygon, and Shape (deep-clones children). Also adds CloneAll() extension and replaces manual duplication in PartGeometry.CopyEntitiesAtLocation and ProgramEditorControl.CloneEntity. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Geometry/Arc.cs | 7 +++++++ OpenNest.Core/Geometry/Circle.cs | 7 +++++++ OpenNest.Core/Geometry/Entity.cs | 25 +++++++++++++++++++++++ OpenNest.Core/Geometry/Line.cs | 7 +++++++ OpenNest.Core/Geometry/Polygon.cs | 7 +++++++ OpenNest.Core/Geometry/Shape.cs | 9 ++++++++ OpenNest.Core/Geometry/ShapeProfile.cs | 3 ++- OpenNest.Core/PartGeometry.cs | 16 +++------------ OpenNest/Controls/ProgramEditorControl.cs | 11 ++-------- 9 files changed, 69 insertions(+), 23 deletions(-) diff --git a/OpenNest.Core/Geometry/Arc.cs b/OpenNest.Core/Geometry/Arc.cs index 0ed1272..d51a4c9 100644 --- a/OpenNest.Core/Geometry/Arc.cs +++ b/OpenNest.Core/Geometry/Arc.cs @@ -267,6 +267,13 @@ namespace OpenNest.Geometry get { return Diameter * System.Math.PI * SweepAngle() / Angle.TwoPI; } } + public override Entity Clone() + { + var copy = new Arc(center, radius, startAngle, endAngle, reversed); + CopyBaseTo(copy); + return copy; + } + /// /// Reverses the rotation direction. /// diff --git a/OpenNest.Core/Geometry/Circle.cs b/OpenNest.Core/Geometry/Circle.cs index 05a03de..ac25197 100644 --- a/OpenNest.Core/Geometry/Circle.cs +++ b/OpenNest.Core/Geometry/Circle.cs @@ -165,6 +165,13 @@ namespace OpenNest.Geometry get { return Circumference(); } } + public override Entity Clone() + { + var copy = new Circle(center, radius) { Rotation = Rotation }; + CopyBaseTo(copy); + return copy; + } + /// /// Reverses the rotation direction. /// diff --git a/OpenNest.Core/Geometry/Entity.cs b/OpenNest.Core/Geometry/Entity.cs index bd2e7fe..723ddc6 100644 --- a/OpenNest.Core/Geometry/Entity.cs +++ b/OpenNest.Core/Geometry/Entity.cs @@ -251,6 +251,23 @@ namespace OpenNest.Geometry /// public abstract bool Intersects(Shape shape, out List pts); + /// + /// Creates a deep copy of the entity with a new Id. + /// + public abstract Entity Clone(); + + /// + /// Copies common Entity properties from this instance to the target. + /// + protected void CopyBaseTo(Entity target) + { + target.Color = Color; + target.Layer = Layer; + target.LineTypeName = LineTypeName; + target.IsVisible = IsVisible; + target.Tag = Tag; + } + /// /// Type of entity. /// @@ -259,6 +276,14 @@ namespace OpenNest.Geometry public static class EntityExtensions { + public static List CloneAll(this IEnumerable entities) + { + var result = new List(); + foreach (var e in entities) + result.Add(e.Clone()); + return result; + } + public static List CollectPoints(this IEnumerable entities) { var points = new List(); diff --git a/OpenNest.Core/Geometry/Line.cs b/OpenNest.Core/Geometry/Line.cs index e0317d8..9b4474b 100644 --- a/OpenNest.Core/Geometry/Line.cs +++ b/OpenNest.Core/Geometry/Line.cs @@ -257,6 +257,13 @@ namespace OpenNest.Geometry } } + public override Entity Clone() + { + var copy = new Line(pt1, pt2); + CopyBaseTo(copy); + return copy; + } + /// /// Reversed the line. /// diff --git a/OpenNest.Core/Geometry/Polygon.cs b/OpenNest.Core/Geometry/Polygon.cs index 22e2131..9ae1cab 100644 --- a/OpenNest.Core/Geometry/Polygon.cs +++ b/OpenNest.Core/Geometry/Polygon.cs @@ -168,6 +168,13 @@ namespace OpenNest.Geometry get { return Perimeter(); } } + public override Entity Clone() + { + var copy = new Polygon { Vertices = new List(Vertices) }; + CopyBaseTo(copy); + return copy; + } + /// /// Reverses the rotation direction of the polygon. /// diff --git a/OpenNest.Core/Geometry/Shape.cs b/OpenNest.Core/Geometry/Shape.cs index 6e35f1c..6cd9358 100644 --- a/OpenNest.Core/Geometry/Shape.cs +++ b/OpenNest.Core/Geometry/Shape.cs @@ -349,6 +349,15 @@ namespace OpenNest.Geometry return polygon; } + public override Entity Clone() + { + var copy = new Shape(); + foreach (var e in Entities) + copy.Entities.Add(e.Clone()); + CopyBaseTo(copy); + return copy; + } + /// /// Reverses the rotation direction of the shape. /// diff --git a/OpenNest.Core/Geometry/ShapeProfile.cs b/OpenNest.Core/Geometry/ShapeProfile.cs index 23928e6..e73632d 100644 --- a/OpenNest.Core/Geometry/ShapeProfile.cs +++ b/OpenNest.Core/Geometry/ShapeProfile.cs @@ -75,7 +75,8 @@ namespace OpenNest.Geometry /// public static List NormalizeEntities(IEnumerable entities) { - var profile = new ShapeProfile(entities.ToList()); + var cloned = entities.CloneAll(); + var profile = new ShapeProfile(cloned); return profile.ToNormalizedEntities(); } diff --git a/OpenNest.Core/PartGeometry.cs b/OpenNest.Core/PartGeometry.cs index c30f6e0..573d641 100644 --- a/OpenNest.Core/PartGeometry.cs +++ b/OpenNest.Core/PartGeometry.cs @@ -126,20 +126,10 @@ namespace OpenNest { var result = new List(source.Count); - for (var i = 0; i < source.Count; i++) + foreach (var entity in source) { - var entity = source[i]; - Entity copy; - - if (entity is Line line) - copy = new Line(line.StartPoint + location, line.EndPoint + location); - else if (entity is Arc arc) - copy = new Arc(arc.Center + location, arc.Radius, arc.StartAngle, arc.EndAngle, arc.IsReversed); - else if (entity is Circle circle) - copy = new Circle(circle.Center + location, circle.Radius); - else - continue; - + var copy = entity.Clone(); + copy.Offset(location); result.Add(copy); } diff --git a/OpenNest/Controls/ProgramEditorControl.cs b/OpenNest/Controls/ProgramEditorControl.cs index 0941463..0b026aa 100644 --- a/OpenNest/Controls/ProgramEditorControl.cs +++ b/OpenNest/Controls/ProgramEditorControl.cs @@ -209,15 +209,8 @@ namespace OpenNest.Controls private static Entity CloneEntity(Entity entity, Color color) { - Entity clone = entity switch - { - Line line => new Line(line.StartPoint, line.EndPoint) { Layer = line.Layer, IsVisible = line.IsVisible }, - Arc arc => new Arc(arc.Center, arc.Radius, arc.StartAngle, arc.EndAngle, arc.IsReversed) { Layer = arc.Layer, IsVisible = arc.IsVisible }, - Circle circle => new Circle(circle.Center, circle.Radius) { Layer = circle.Layer, IsVisible = circle.IsVisible }, - _ => null, - }; - if (clone != null) - clone.Color = color; + var clone = entity.Clone(); + clone.Color = color; return clone; } From b03b3eb4d9897465334a5d300ce6cb104a26b4ab Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 12 Apr 2026 21:36:21 -0400 Subject: [PATCH 15/20] fix(bending): detect bend lines on layer "0" in addition to "BEND" SolidWorks drawings sometimes place centerline bend markers on the default layer instead of a dedicated BEND layer. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.IO/Bending/SolidWorksBendDetector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenNest.IO/Bending/SolidWorksBendDetector.cs b/OpenNest.IO/Bending/SolidWorksBendDetector.cs index 061cc24..15b5d54 100644 --- a/OpenNest.IO/Bending/SolidWorksBendDetector.cs +++ b/OpenNest.IO/Bending/SolidWorksBendDetector.cs @@ -133,7 +133,7 @@ namespace OpenNest.IO.Bending { return document.Entities .OfType() - .Where(l => l.Layer?.Name == "BEND" + .Where(l => (l.Layer?.Name == "BEND" || l.Layer?.Name == "0") && (l.LineType?.Name?.Contains("CENTER") == true || l.LineType?.Name == "CENTERX2")) .ToList(); From 2c0457d5039b2c6f2c40575755a1c665059c42c2 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 12 Apr 2026 21:36:26 -0400 Subject: [PATCH 16/20] feat(ui): add bend line editing to CAD converter Add Edit link and double-click handler to the bend lines list so existing bends can be modified without removing and re-adding them. BendLineDialog gains a LoadBend method to populate fields from an existing Bend. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest/Controls/FilterPanel.cs | 20 ++++++++++++++++++++ OpenNest/Forms/BendLineDialog.cs | 12 ++++++++++++ OpenNest/Forms/CadConverterForm.cs | 24 ++++++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/OpenNest/Controls/FilterPanel.cs b/OpenNest/Controls/FilterPanel.cs index 9f245ca..5cc07e9 100644 --- a/OpenNest/Controls/FilterPanel.cs +++ b/OpenNest/Controls/FilterPanel.cs @@ -27,6 +27,7 @@ namespace OpenNest.Controls public event EventHandler FilterChanged; public event EventHandler BendLineSelected; public event EventHandler BendLineRemoved; + public event EventHandler BendLineEdited; public event EventHandler AddBendLineClicked; public FilterPanel() @@ -51,6 +52,18 @@ namespace OpenNest.Controls bendLinesList.SelectedIndexChanged += (s, e) => BendLineSelected?.Invoke(this, bendLinesList.SelectedIndex); + var bendEditLink = new LinkLabel + { + Text = "Edit", + AutoSize = true, + Font = new Font("Segoe UI", 8f) + }; + bendEditLink.LinkClicked += (s, e) => + { + if (bendLinesList.SelectedIndex >= 0) + BendLineEdited?.Invoke(this, bendLinesList.SelectedIndex); + }; + var bendDeleteLink = new LinkLabel { Text = "Remove", @@ -63,6 +76,12 @@ namespace OpenNest.Controls BendLineRemoved?.Invoke(this, bendLinesList.SelectedIndex); }; + bendLinesList.DoubleClick += (s, e) => + { + if (bendLinesList.SelectedIndex >= 0) + BendLineEdited?.Invoke(this, bendLinesList.SelectedIndex); + }; + bendAddLink = new LinkLabel { Text = "Add Bend Line", @@ -80,6 +99,7 @@ namespace OpenNest.Controls WrapContents = false }; bendLinksPanel.Controls.Add(bendAddLink); + bendLinksPanel.Controls.Add(bendEditLink); bendLinksPanel.Controls.Add(bendDeleteLink); bendLinesPanel.ContentPanel.Controls.Add(bendLinesList); diff --git a/OpenNest/Forms/BendLineDialog.cs b/OpenNest/Forms/BendLineDialog.cs index 797ad8a..130119a 100644 --- a/OpenNest/Forms/BendLineDialog.cs +++ b/OpenNest/Forms/BendLineDialog.cs @@ -99,5 +99,17 @@ namespace OpenNest.Forms public double BendAngle => (double)numAngle.Value; public double? BendRadius => chkRadius.Checked ? (double)numRadius.Value : null; + + public void LoadBend(Bend bend) + { + cboDirection.SelectedIndex = bend.Direction == BendDirection.Up ? 1 : 0; + if (bend.Angle.HasValue) + numAngle.Value = (decimal)bend.Angle.Value; + if (bend.Radius.HasValue) + { + chkRadius.Checked = true; + numRadius.Value = (decimal)bend.Radius.Value; + } + } } } diff --git a/OpenNest/Forms/CadConverterForm.cs b/OpenNest/Forms/CadConverterForm.cs index 53fff87..7958bca 100644 --- a/OpenNest/Forms/CadConverterForm.cs +++ b/OpenNest/Forms/CadConverterForm.cs @@ -41,6 +41,7 @@ namespace OpenNest.Forms filterPanel.FilterChanged += OnFilterChanged; filterPanel.BendLineSelected += OnBendLineSelected; filterPanel.BendLineRemoved += OnBendLineRemoved; + filterPanel.BendLineEdited += OnBendLineEdited; filterPanel.AddBendLineClicked += OnAddBendLineClicked; entityView1.LinePicked += OnLinePicked; entityView1.PickCancelled += OnPickCancelled; @@ -292,6 +293,29 @@ namespace OpenNest.Forms entityView1.Invalidate(); } + private void OnBendLineEdited(object sender, int index) + { + var item = CurrentItem; + if (item == null || index < 0 || index >= item.Bends.Count) return; + + var bend = item.Bends[index]; + using var dialog = new BendLineDialog(); + dialog.LoadBend(bend); + + if (dialog.ShowDialog(this) != DialogResult.OK) return; + + bend.Direction = dialog.Direction; + bend.Angle = dialog.BendAngle; + bend.Radius = dialog.BendRadius; + + Bend.UpdateEtchEntities(item.Entities, item.Bends); + entityView1.Entities.Clear(); + entityView1.Entities.AddRange(item.Entities); + entityView1.Bends = item.Bends; + filterPanel.LoadItem(item.Entities, item.Bends); + entityView1.Invalidate(); + } + private void OnQuantityChanged(object sender, EventArgs e) { var item = CurrentItem; From c386e462b24f69e76bb4f9d91af81c3663aaf5ef Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 12 Apr 2026 21:36:39 -0400 Subject: [PATCH 17/20] docs(readme): add CAD converter section with screenshots Add a CAD Converter workflow section and inline thumbnail screenshots. Rearrange existing screenshots as side-by-side thumbnails with click-to-enlarge links. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 16 +++++++++++++--- screenshots/screenshot-cad-converter-1.png | Bin 0 -> 66022 bytes screenshots/screenshot-cad-converter-2.png | Bin 0 -> 83875 bytes 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 screenshots/screenshot-cad-converter-1.png create mode 100644 screenshots/screenshot-cad-converter-2.png diff --git a/README.md b/README.md index 5288adc..ebefd90 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,10 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges parts on material plates, and exports layouts as DXF or G-code for cutting. -![OpenNest - parts nested on a 36x36 plate](screenshots/screenshot-nest-1.png) +

+ OpenNest - parts nested on a 36x36 plate + OpenNest - 44 parts nested on a 60x120 plate +

OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and arranges the parts to make efficient use of material. The result can be exported as DXF files or post-processed into G-code that your CNC cutting machine understands. @@ -46,8 +49,6 @@ OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and | **User-Defined Variables** | Named G-code variables (`$name`) emitted as machine variables (`#200+`) at post time | | **Post-Processors** | Plugin-based G-code generation; Cincinnati CL-707/800/900/940/CLX included | -![OpenNest - 44 parts nested on a 60x120 plate](screenshots/screenshot-nest-2.png) - ## Prerequisites - **Windows 10 or later** @@ -80,6 +81,15 @@ Or open `OpenNest.sln` in Visual Studio and run the `OpenNest` project. 5. **Add cut-offs** — Optionally add horizontal/vertical cut-off lines to trim unused plate material 6. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code +### CAD Converter + +The CAD Converter turns DXF/DWG files into nest-ready drawings. Toggle layers, colors, and linetypes to exclude construction geometry; review detected bend lines; and preview the generated cut program with contour ordering before accepting the drawing into the nest. + +

+ CAD Converter — layer, color, and linetype filtering + CAD Converter — contour list and G-code preview +

+ ## Command-Line Interface OpenNest includes a CLI for batch nesting without the GUI — useful for automation, scripting, and CI pipelines. diff --git a/screenshots/screenshot-cad-converter-1.png b/screenshots/screenshot-cad-converter-1.png new file mode 100644 index 0000000000000000000000000000000000000000..c082736046d2807466d2a6bde5d3f8d816333869 GIT binary patch literal 66022 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 C=E-gV literal 0 HcmV?d00001 diff --git a/screenshots/screenshot-cad-converter-2.png b/screenshots/screenshot-cad-converter-2.png new file mode 100644 index 0000000000000000000000000000000000000000..d24aab705e16758909f6ba8d5de26f3d973aaa05 GIT binary patch literal 83875 zcmb@u2UJsCw*`s?R0KOn#{wd~_bMnwnv~F+R3Y?Gq+=7JBHQ zl!Oq9l+Xk31jXRNJuDEl<#Yk zkdUEBNKPf4B?Hb-u-IaNUnd~iO7bK{NCqtM$7#EJ>i0-UO5UH}e@qJeea=bf20nP6YVt;#0Q{w>BJBjhv#PSFIe{)z$f$gg#DdpE;>o zz)hywn*+(TOC zUklKN?;z9}9(@#P;_i>bU5h6)K-YgCH1NrQEmP7-U*l($Q+)-w-Seqahh|Z;t53rY zMX$j~vÐ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

N!Hwd#=<<~9V#nD^XUef)%sovlq zRRI)1s;RsNDq<`rqY{f}N4avT-k}V~$l)B>ml^fs#O2aWb79|HrH*Qm;LT?T@R;+u zSICCT#A$Qr6?Vm(@rc_pQQkkABrvcl)CgQiYl5P4TX zQD&nfVN~}w*Q3Ie;sLgn5-sK@Z6P^-)@TK9J@(=r&};jezrQ+gZ|7Qui;#@`5M~n< zjR`O7oAYh1j8ale$t>7yee3(;d%Lu}E5Qj^V51zAP23f_!DmWtmtgmpr%RQTJl6hS zvhIg@uFjjC^e)|o|9cvpg2B%h1NH8fNI%fGt|w_@1_mX*L@TpSn5^FYRp*{T-)s!I zo__2>Z=Whk0}J7<>lYwripfGCrT?^&wy+v(5UM>Oc6<&iHi;9@8%SFAr64bOp5jS} z+mh+KO6?$Ztl(PwiQ+QEu7klxgUzigLT{y4>;~?)_7!B_BzPc)t)!H}Flpz@ssFYn zeAh`yA>;G%kogM9V*9|5)NJ z>a#F5avVL0j^189HUL3)xUVoFLud%bRG@pXUz*0E8hyVpX!5t1-GAEMD+BwqBl3Zi zJgn=EevP--DtnNMqN|;K`yP*Dt=m3bt$(L^40K#k{CU&+N&sEc^D*s&RRAr{+>Jtf&^h#DzpH-TMCDjW z+T?9LFSH5ZdS0|>GQ}J%903#2(hRz5;?1{QyeG7+2@a%}I%_=%U)zyJ^DR zBbB6YP9fe;l*I>C8eqabqxBfhXnqX>0@Yw(9lsQs4|~<5X!K?L0O(+m_w|_6vkM&F zB{%DgLrM|1W=S3~9Utm<66u#cW!+01Uc+(OS7x?Lm>E0x-_IEdScXRNyOb1uxJ7`& zO%H}Rfq!mW96>ca>ly{yA3yTW+&vg>Ul7?GH+AVM%l3{-D;LPv~he7Ze zlN!SPBIhB5XFsnRBvztcI7mt4*^#Hq@7SsAYcJ_&XqCSBs4|h?r$zzq!V`&d%s$+! zpj3iUXDHDwBa>z|i}gQtVRuN~0o3#{zr}cz;7i-u+Y4G`-wKH9n8NP;r~NK&>%;PF z)2R#EKoAbb4amXfQf9WZs>Y3V^2t#QlHmRYuuHG7FupclFYTpLTCKNXO(`WMk40sl zJ$q+mIBEoeM6;}9T-oT7$DcbbCiriS}Yx84uq(jI3A~)@=&ktJ>C02o8u*b9Rx)H<2fr*T_dTdVi9~^y zXDT7AM{(i``$rj5YHo*47DY#;*V&ZS@}#xO<+$@IRbo4@U=uFTw>iR*K)nNn<}a*+@37B&9U8F zTxqHBr^KJ$&Tx3szvrtD5AePoS3b=1?l5fEeqT_sRMOvj>$nBUP4IbD z=$a-Tu))vU57t$ujnvG_!nMb37$QYhxTUv_n#h*iyqXu3E8a}YGFD8MnUZ{edkK+S z=$C&kD)$0w*ynZ?J1_mJYNey?RlCF)+VOnCWud#QdPPFEu;aqnbJWl zTO!YBJ1oU1w{4RQX`Bcg`tCPo2l8i*G13%h=H@>AidYao|MYVL$*OJ(I`{k^f^GoT z+EB{82vM{{ykP%w_|$~A2D*MNMi_lC#q5Tk@@%h@-Z%f%k-rPn*M!`zE6A;N44 z#>$@}6|d`G)Smc3FD{N+metptOu2(QnC@)w&YuPm+);OV*YVzS4$O$iGO2SBLV57B z%5lF|EhsstJUsJ_+0M1@Grr_1L}1K02>o1#c!39JarF%qc-yq|2;AZiPq(huSp{Au z7cN{dhCsxtv?cnir#g4{@*wt)B{-L3GRB;?=gO5j_y!ZVbW!Gc`XBT@b^-hmFw+=xH{%{RFY_fm9OnQ!!PeM$Zi`%kOfWDc?DmH6 zG&mJDvKb&X!2V+6Q~51jpg6z~;iy3ks`=m2z-$G~G&&9v3s(H_Dm~l55V017BMLIG zVrTe(Hix$kNCw@%2sV?l_yZsB#<-zv*6>kDo?g*m2@6aA+|OE(Bwbu8KTs;XB-sS` zI(Aui3AvAZBSt38(wddS4q?VdJ$Ufott3VjC-e>U${us6>K~YMfoegj8F=#V6samg zDD%sh#W!I+cyRZhU^5I}fZSM`^Y6)4!Agr|@3@n{R4k1uW(NXPEEd%{Rqzi)e6?{S zo>Dk1K05g2C4kBPJGq|T^kh;-tCaIwdJ9wTq7M>!yNi&&Ezjd?P>*xk> z_#x&%cfbH#ae{(xy94ho25a5x<6tQti>S^3A&p`c$taZd&eB)cZCmLYCw;$E(ID<* zJ#wL?rTfTKMbolQLg#}lb;^gP-KG`o>tp;`3QgWJl6-~Y!W~_Qos!MzH{K$*(v!$0 zj|GTB3itVxGRob?6BN3LyKHwTU4oK_j|{83*dXkTW9V2!7R{i-!W_B89&&^{vwCcG z8O!~Ox89wTqHyo$1m4`P$3_iSory+GCnumntHbd3D(CP-jr-`+sEuj{LLZCRu^UbK z9ouueNYzqMZPp{3y**7X`ob|2vu^QFT($*ym#S<-B5)3>I+tK!EQeD5c-Pgl=4#G` z$z;f~cG`?*Qq6NEm`XgpPGyMtN4l|Q7#-pimuLVXt@7HjZ8s<8{(OJiTAOQ}%N0>C zIrK*GZ1Qm1fbk}4{KhELLHpy?;3KmBl|zU81Dt5(F7jsjC2l&SEd`8qmu!Vd(7Kcy zOPd$iZ^3sZ(}leU8-Y)lvp~C!s+9#`)soRQkCBfo3m>bBKjqg-8$N~UOzkD34gw&X zq9;b)hNHRQIB0*An;%}B?=o{dKRS|3qU|9#5j36`pRY?|an^QKJ!M97vPZaG5>}O9 zR`eoqyvts4UKqXQ#k2hTNx|VKYaoCTRB0*Sg4|{e=5S6HQ$ISYFsG27N4DNKxRE9F zz}VZNS)$xWErEWOp)bz8Ckq6HPeP30o~ZVJIC~Fw^=A!n9*wOunF=ZI80Pa^isrc9 z(+w!8jCs?*{pTxvJ&%vd1PzYNL_;=Jrg;l@o& z`>g;T-$noXNl;Y3es*D)Id8FwO@J7zTAoufc%x7P@hj%hAPKh-LdRvxaemGvdSkuY zGN9Mghl;21L{&J~r1uBT8$-Vw%FW&`7PE5}hXxt{;jV#_o&;nu4(eQ{$Lc9Z5b6;U>!uoT++t* z4pTl(aui%Zp=d-22nae8%8s3So<~(IM!m_OEVLPT{jBYo2PBkniC+v8Yf{FC> zB4!3s5c%${yy~ju*Rj_B00PlR!|0>0=_|bBXxDtZY`H<3&UZZtf+CI~Z|Yt?|J0JC{kq?nPjg_QDUzB1R8_Sb9s~(Z*)Xe zaqYY>957^mb!oU($Xev`JGixR6|_lid+VTidjvUtxFz+|Yu}zf6L$Y#*V)avnor}y z&f38tRzN=|?AN$v-`KcV7b5NW_9F~}g6uoOh_JBM0e1LHgfFM~Xj5Zp{Q0#%=%ha8 z+$L4xP$REHfpKDUz7?YtH&l1}!JmU9S#-hV+&5{FeUJ;cj6tu!GH!F*$RpOYu&47V zPe3UBz_g9YFdJ1J%DDUDnxi>HlyGpO;IxD3*nXw#;uIEEyDngvi&S!YO?$mnON*8i z7z?G2$0heGDr=Lrro%ceDuVeRTSs{Po+-4hBgmA6^VgapB~yM9K%Wv>ua?+!J9Ze^ z6RxDKIUi+R)pBm-KZjLY2^RLpkLk{(n~oNYeKO%@(;6i%5&yAOE%;tQxxar`iot+y z(i;7=L$*T`blInr;l0#+W4*y<)zL1uEA*$w>LuGL8}#G_T(0=&H8hyS)WAg7*JN^L zSe}vGMT&E&@AwPu7`DJfp{-&q^aEjxFCFizSQ5U-xrd!?TUhMIK3JgFW^3B1W*F-| z8Ryt}UXU+$ke()O&Z{ITQ6*?QYh%|hP@08ig^SCJfxu);YMMl>Cm@^wrzoqvjo!bq zGd7(KpF)KF z9&SY|sUG;84#_b4(t&?viDz#t^2TF4tXgOGI_+$!SU2R??e;-orRK#;B0Og4)aOT+`@QigJ(nMaxd$o+8t{ik#x`o7SBc+N z9$`{NUMYFA-w9n=>RV+{NvqE*@n5x{_N#e&yN$c5bfaQLE@Vwm^dWX=RwLbI`K;_v zHiSb@T$hv7IQ`3qMPR1VPOMWc88}d=(ma4VR;lJo?xp$MI2*^0+@bXvI68it+;KvS zS^0Uw3mQwm7yb^)x-g5xXDhL&Vku(qd}&%kmuTfMAK}fq*dQCjbuxJH(mix&M9AK; zk*e1;PkF}D{>DJ#>V&vxl6#%Y)qD(r89(v+IlVo!uNXX`S; zESLw>vz%0*yXn87(?a+1T$Pb%5m|Qhg&EY+Li5x!PQ>MVw(9f8C%i`s`fS! zJ^riZ+Stt@v!S;W^pdHKS}gg3orUep#9@5t2|o=&Ilu7xgA&}ARH8yrRwbb4|TE@*vHJR31}`8HXZ9jtPmH%r$|=<+52^ zfnm)wQhWx8O@}o^`mCQ? z>$>XttIzoYYCobzHg%p8Ri*F&hf3g77Aie#j+Gn@#$ zXya-m@IbBYC_u(#Z7|Jx<;eaK?~dqCQg1T07Eql%X+E}w$i6KSC_i%V`Cd34)vt=Z zTq+pR<$>OfdOEfj&W@3XB1wBp^;)cA>P1c|#uEX&dvBT)&H^SITrVc7QpUlVgxPqE zW?DrhJp0nLi*TJWDVzH`Jl~uaxbWvI)nJBIp4)P<`C4p2f#~yE-pfP==iS>H8u+Zn z%YSXQ4bd|qVqEy%Rn6XXs7?#n_SIQ@25j>CtP|Rd+7qOFHT_9yB`m11nhi0PVSO64 z%&^(n1+9zq=i)y4kj)%ku_8}m&9esjwL)9A^AFz>e+9{>4kC0`P zJ&Ae0vZP|kd-rPSLb>bT$IlHCcexaF7?-@z^ez%F(#T~|QdP}VYCtHbg3=V)n64pq z`Yw?@w5_^R-{v?WjGz7aWN93sIu_T*Z86!fOOhE`$(IJtF1}I{5OYs_AOp-jllS1j zWWFQ8po}hHRGzk}l*7iRIxi~~K6li*06!WTMK3M{h&60oyuE+P4O^s;Cb?>X{vCwc zx}EAv=5+Lc=wWNg2fC($&HmmS8hm4419A|81ZzTsWX zZF{7ZzY>^mJOjLH%;xLIZH!q=oIP^uBoB}u)h4F+(MJG5Y^M~hv7ef)raXER;BR*T zrOvS443LL8-ajvk)kN=-@N-jWrQ?Tf1&t^$u5V>TERWaZ_V@qj`gj4G@I(8#!r}Lq zt}P0-x-yOPv(HyoI%r5StG%YYIOEH_ee^DF485}TTD$cPqwA{!yXxX==j1%&ts+pV zQ`_+BuACvSkkCyJ7jW3vk1z8pHJSPOk7#IV#e+zCadqSf^kqcqgPh2ir!wK=iu0}dR^yn;qH2^$<~j$bLhZ=__oq(5-?_S&B4Al#UZbETvKfa zZeP>34=Y@VBUn{@DCLe~&6gT#Lnda)W?=I>F2yT_^jmlJ^s2NeLEp81vxm+#HZghC z($W%?+;o8U)z022c7bg%A8Cp97K$I*P3!Hil*pZFzj!q71~f@c;QXx!(*1XAskiG$ zQ*R5*vJ6TzuRt*mubt{cnsor1XGG^?=|2QZ1ZS`?&8VY%q6@o`^%}Nng=dAd-mezJ z6s|r@q>dDm$9;PJseoGFz>sf{`&FP6pZp(|?`cJ9d#Ze0sB%j_n#Ze-TI+Z?`@HyK zQ*5TKs-zDG(Z@oa($Q?S>6*%roXHuXa){E4Cpj_-Ie`;%tdEV%AxWdzewL-niWWj| z>1qZ9DENs`yyYdTo!DBsPo`A16qCJtx#?}&kd>m6jpcwSy?h+p4{qO`}+wlgvpMVoYwoM zn?IiAWM`^zWX4VWkT#*0x2a9}B50Gzlq ze6bw2ah#Hdt1KV=VB?i~Pa9adI3409FOzPt=EI;)&iFp*c8~<^hWEQZPrEdQ1XAl4tfF-Ycf7+)8k0rrfMgH-O|WdCmdrGP%5z#dSJTK|6W!G(vHnk$!?Z@yVG32;ZW3ZvdupsX(xJny`DO~{U%8pK z92&SamYP z4X}7mse`X$&gXb$RY)h;gockWW~8u4@(33eNR+EaJv91(?eK=L3-P>H|A()nt@s5! z^EtnQvl-~*PGvmj`A<^SRMFW6$}iOHa1N>#!sG*k_2Rvk7mbQV=pKxaQLr=}H~tu1 zTrLX2cb9Iy#(hgk_?pRGcskzeC_H4#Yfl&b!HtPoVnoxP>4XL=_M22+lIKfeq!`(MXOLdd8D<5EUY&^ zP{{M%H$@GK&F={tzE5WLc+)C3G|4%jBRTA`@=`GIXnfO=p=p@_N1Fx{boy!zpx#7+ zZ)C-0)5(iJE94(7?ti=<Y$8k*Y>#=qkwkkXUnfgn5gTNCe4& zptuV*8czw+O{`K`!)L@621FqRgn}D3!rdB4SzyqJ47CnV+Ds`Teo}_Ge4!&OY`{vV z1B}?(W>JRUU%&kEPWe7nZSlSK3oJ`Hql1k_FA-W~2_L6I(XjM!RJ{Tl1F%A&DU~MM z8^MT6T6p2jAn3mQnUz5jmQmS6(1TFD>6N+tynd^6rlJc;5GWXc^@$A@*3yNO7RpU7 z%I9n%0x3w1FDP$|`8dVhs1*%j?F~tl@f+Rh;`>krj89m$_}BxTiWzw+Pdh00wk3V; zS%tfj^e)Q?H7W<)$b5M~Xp{WyBJNm%m*mEiwg$_~shN z9gBw!>~XY^%3Z@FMyHqCaa+3v)LhVg>&9URrXQLlW1RCgk2-Jjrd>m%;2xHr1C89# zN!_XDqmP9E0E}gNna6g@R|X}_hnw-QXFbN28@@sKG&Wm|wLa^x4G~JhTUyXS1Pe#h zKFjXR{1S!s%j<9+H?2u^tNf~R5N`L7Esx@e@9d?hsnCUASHSH+NjBz;Lg>CgIX!oeuh0}nc1*8D2Gc<0gP`0fBdZ|@WEOliYfQ7=eqn4MMXuS z9kma^z1gM9GSa;glU0K1*PpKZBV+ zlwK)yPYC~bLkmprEn0ep#hZ6Xt>k~INA-ydy940lcM9H1^&GHm;E96!-(*n2!}*o~Ys zR&vRi9GURc_%2Q>O+ZA9QeQkqFDPj0{dc{t0~8JmD;4DWvEYx|OXXH=#hDE?gZ}PL z^7#%?M0Az{L>|Lmd;qIJiVXAq{1uKWTZPtADX`Q~nU_(ze!~{?0az?HVA&_UU_ns5 zn=|IJc>#UiovU+db;t`q3E{%zlV>9;K8uxtm`2`7bzPHMNO%}hfoY)U^1E)#iM9FBe7 z2$V5sG%JthY{~E{zYkO$55XN6y_Qd1LhUr!J5BLbmE;P}0AdPU`XQsE>gaBE)pUi9 zm#yeMk1^&WmS20x;z4n}d*`|qDg}$(q&Ip&{E7rmY>8FPeKxbT+!n zNMxQa$6{{Eaw^Jisn?T~?JcNjHVRPEj1)=}(QwCtr5co{*&JV~{DFP~MJeS)ZB0V~ zn^)!KhVobEFt=OJYuxt(kR;QPER8$ZS4%t#7a6 z>r%{lbtCSKI56X6g5;1O_43x+%e^G)uAOi$F**ORW~k@y0Y*ROMyJdaqi3VSBl^7q=|dnuW{>Qn2f{lGsbIN;qJhveqy_2!|@Y z!L6lE>$~g1mv~onx7aUJ4Gvu&OlCJQjBXb3^tZcM5A&mU+FV`zu4gToCJ39ka^4h7 zI{K%Eh+1Fk?H!{kEs8P3qe9yXb=cQ!2tT~4JL8JD6KiG7D_7H`lKJFJNasUKUcwd| za;|=8?N` zUSC)aRC3fwARMOMx1t|3-c?$1teFo>{;0b1VFTHHev0FW!q5xGUH}%h`q1Wt1Q{c} z$j7dm{+ZD^;$ljB_`TnyJyuo2h4~iXxP_s~p_u&#PfF6X%yUlANvMuN>)SSqRw!st zym5ILDvd^~r7Bl&eCQfz9EGH&t1RQ)hqr1&=1BXOHVig{KVM!VuefNVrirWuXO@7Y zPu}0~fPPDXmSNCf8%fHd0J_{a!7*!>bdRS`qvPq5>%r}7pl>9}`hj>(cNK%19>tF| zjE$rP1>cjBs#+Vh6E8v5131zX5+q!<`fh4h3Hcn=H@*0UFzv}9bI=IR$YmTj0H^nu z-bF3Ssr|6>;qV%sIO*C9> zFDL!`s8+U!!@Q=0d9;<&vSACzyeN)edL`2pCy6V9>3FrPy+N^cRl_Z`QM-Lk!4lI(#P=F+ z3v3_nLGA1J5Tw?1`GTPk+5eBYw+@SP+xv$xFi}J|AgvM#NS7d?AdMj1C6dE{beDxn zNjHe}0E5JUG>C%IH4Kf2ba%sV-2Yi-j<-iu*6GiYuvFMpYex@QpHVf{go zx+ob@IJljl77ydMxpN~M1EV_gxTHTQ3B=oNDaowO4XT=%&DR&|RXus*q{rZOk|Y2PAG!y+txw&pfHGV6OzbeTi) zko;4K0PbxpAKc(iw?Dsj!$Bt#mCic$fE8t;zM*4Ing8$;M?$BM% z^fUX!hVa>akUA3dgdm*Y_;ZvMr(t+VU8|G4+hqG^S#K!=Ww#Y=rBv&XSOz~oKZZsE zyaokD3Q`i(Nmx!Nmz5jbo2$6s9-B&Ld4ZyWY1!nIgGv85uJkGHcTKH^i0o`tH&dh- z9}7aRn<1S=8QGR^AzqeM$J`lnb+N3W8C9;cvXX-VZ?R{QfuQOIERev8rX$Lf3=-E+ z(aql6bPna?TWL*ZlT6o8H1$Pe!s}W$cMTyC8f7FnBG1-1U;0G`I?TZ)HljJ1wtXtCU#l(%ldExY`iMhmm zesTZ_tqUA&rFWr)Zc)k~%J(8zPFWq1!Ho|9t7$|x9cAVS^Vw$)yX&p1GipP!42wGT z=x=bLuS#00ty<)gmUO&(0qRrB)97hJv(+;No)J^I`Le!Sxdrmm?VpNYj>xy2o?m{IZVLeG^jrCJB9)_C2ga%@Ux?@8UE^S~y+Ov2l z59r(Rb&9R~g1^WbnsQr7X z{O;hs{P=BR{HOb}U*Z4rM5a&s+l|q`N5C?FrSByRY{6i%Vkyh*GPf&+4*w7ibc@%{ z!KY`O!rcP&btQHCpog-7fq|5)Y#0sK?4URnhMiB_V0`+n%=7ERJ&SH#>~(eh2{t{1 z^`LK^zPv^WbC?@I&w1>*Y_WQp&OfEvzT-p0l#B1J7XY9}unl1Z>9oMJi5f~Uxo*c) zYw=K2WamPLnTUyNGdbL|dgm$|#DJ9r+V+rZ{g|-~&l4^g9``1FWVHXFDm5-lI{ad0 zBd%b)+i_6p(<{6eV}!IK`^P`PyPj$aahOr2-j(kcw?0kh{rD{D>>o0Qa#~uF$_Y4z z!&-Z&q}4m`D8*vYjbsYLBpsJfe3-DvEw{I+u=rZ}?b%|-+qSM>eL9=7)ST$$kA9$? z${+ch>g%!U&d|KpjA+@T#PM|c>8Q5(L}3@d{@}W-YuY7>3T10Af{XVf0I$Nkps#j? zDZ$ z+bE89icZCD6k>AX--S>`%{AKl2O_$|NGurZtPS;L%8rI5uPTujf( z##)uZPEfpLYwRUr&XDa)_i4Cl?U+tp9pmN>ph%8k$R-O^Ux^Aev-I#8xJdcL>g>#} zmc40(=(6oH)xqb!Jp#$ZOh*RRYEF`VD{~Vcyv=d(JjmFLm^mDStVI zOZDz+!8z7r86nT53Yy^RjHxpXSDRB8F(&=x5#QXrPc)uk)iepM+_@(8t<+m1mF8>4 zv)3&$I@zjU>4ieV9!ZF_eWz8Bl6n>wFyOMOL;so+7l3D+P5_!?prxgyPoK;*yz%w& zQiTdthbN8cos+7XV=7Z`*$94A=;S+9cs< z&Nw4Psq0*15>x(_dw3L~3J!j`h)vW~ zZcf4O=+UJMOHK{O4GR@>mMUHvN|#R&?p!PdWiU7@T_4Tn6!2OmxEXC*;G5mpP#?Lk zTJiD2S_z2ltYew3y)YQbcGY}t%(sM<`b z6Wjn?K5Zh!(6j!w&;El9MU{gOtx5{a-E~%D==+36-~&h|&DYB^xj#6YJ=iSf3HRI! zUECQ36dyHht;zd%3{)2#@VqV(PJj+(Hu+M-?I^UTQNA>}_2aYD>Ohcco5R2#0!uRM zQVL-=Rjl9Ag0|LXkmfi{?1D;5UuNB#1@{$uX)}7?_NLZMb46sBkre5=Fr|8PUcsAu zl3R(v{R(%x$($22!G)!BgdNl6`yM>^#b}CzsI`+q#3Q?ymhi`;ZsT{iT{M`2&eU;h z9wFRz`AFU)uGwbEI(t1&+AR8JUpdZ6v|n=uZ|LuBH$ zvi(9wfwgq8PCuYsKZ&30qTMRbNaRb4-6$9f|U)B)e}o0<8Ugfk3qzk zI^N~(keMTZfR^FR(VH<3J)?Q1V>`iPF8>C*!$kPi8A?MXI4wy+lz?MU@<&D+o5(XX zc5AEN4JUi<06ItAXgqn|{tW?bv4zl-pw+^bqEryOgZBAnuj7QJ(fy8d`;;PU^Lj?& zDKuSp;aGk#irY&i?6g|0EN-!$$_&uLddOT+BWtPBMIC>dl_ZS%Vm_oss1Z(N0VW5s zT2=dX;WY(mTPlvcbB|b;CwW#C@=#ClcmnN&o;5TwoXvzq9sUuYb=T6AO)keF5$(%( zR8(uQ_lh14Ln`YZns@gYz_6-64)*n%DK=NJ0-VVPpYP5`hTFafq}Nl#VxcAZtu$Q{ zGCxzBl%zb%^pxIP+?hBHc<+i~18v=RO2pH1Z!O>1b9_9PD+A}gn0agF%5bI&dyIfd zw_H;n4Ea_jnj>}S;Fg0MA93;0sm||99gj%1d=1fQB@iy@x9WXN*U|Tw#u>j2fI8D= zaaX5-)9Fi)LZz|NOR97A4=KaFxS>rt+`;b3rwg9OD3lqAMpZCmDNu~&?N5B*v-Rwy z{!;w;l^eY8oe85+R#ojyeOTI2OpMwcrql1rNWkA$wMjymCm!PS3xZ%o>H-gsHv$5iHW%> zY1UU^X~y^)_ge(k?TFJBrKuPl7dakCYPNsq)d0fz7m60OSp#VEgKu5?9X|`DT!g;t zDW1C1!M63_rQ;I#*AkFHvoiVC<-1V;Nt`A4^i{hrt^n+ViY;ZOVlFesPWQNoM(mY84r>J( z%@1N}pg_Ig4=#_}=&HVyEB4?Zg-)?tzE-fXoM9t93|8c>u^8I7#kcHt zAl;+PdDoT&oY+`U@gD1tURHM2-E-$^GDg}W+%$42+FM#u1h3adS|Uggese=>Yw zOtsQ;y#r&fR>pg~0j3Jzf}NMgg+7SUN19?#*~oT_6!(Y+8AN9&LA5a@eU* zUC^znXK&V_tkt}0nI}Gu^OcdurHF=)w%qcUt&CuH<3ym6bKHmWl^Sv58Z-GzI*c+> zrW7@+>76*=HC1=!5NVSFwtGrvbd0a{_1%MO1qGz}!>TCdK~Xyf@ooY|M(R_qJ72Sv zBq{kf`AjOtGu!hiX$0x*pu5t<$P9;4t9!yYAjMQ-jlV+}m7okO>>>heGK_9D&ij1EUQN=ro zLnjTObgwc7Fenuu{X29tTnoMgJ~@5piOB71FI0?B>J9dR-Og71X|h}~ZR5qnwZz+w zNrnJkQxH!TJX7&O0i#5^=Api6a*?s1h8Z>f#K8OEn5SFuL?C}EX-CUrwODFu(sMW2 z?oa7($~EbvX!)7lbln)fFr$-ho#bT8ka-dGBL~N;sK@WQL9c&~Cp=y7VVY6+&+go0 zaa_XBBlJR1{n5KxtGQZ!_liI0PLgIdu8bQ=ls__!y}V|4Ze-Io$L1b&thZm0BIQ>b z24OO3k|P>=0Y#g)?)KdiEe7EmXHRAU$_%QW+0$h!Bc`L&3TS$!lU?Uc8aS_ zUD1OqFRN5UY*_olM$w_nE+&U5l?@ga0O}RV&-M`3^!ncK>FvSdURfo|F$DZPVcQye zyaY`)a8!f(tRWQ-@Fq%(sbk3f7H+yQwuy+vLZJCT(g&($5kZ&brG{qiHZVo9O3U0- zBP&S?5W^q@Z_-aHYJS2=mE#dGwSH!3?8eS_IZP_`S}d{XD*K4@lPtn53+Iqg2|xF} z9O>e3U(va5MvO8(;U#>p$b|m^9}69oDoQ30^ikq%6YJ3j<*v;h^~m1IvRl@wbGmE;y9;Oc z?)t1yt)y;Pjeh^oW(#B=^-9Y6__09vF&fbzZDwMkB;_#v^-bQT<&~FCjh3A3F^PV= z4D71N#TPDicC5>fC9PF56$enVv6LbCpAzz6ag`b!Bl2?2{yHyJMY$aTb}UU+=lhMd zqJb6_&x$1R`KvCYPgj5#P2+9^UfDbQ(k@+|GDE79tkOdmKCzn>?d9$9)0ziH{C zsnP{!kh}Je0z3V;WnEU(I*JzChbq>3c^U09kY5r4R~_>A*5A8nEK)5!GJc}0!l;A> z@dT2jvkC_HO`fYkTtQZd?aZT)sjLUeQ+$5YPQ9n9v@}Ns=qZ?Zgc%LW+MM=WWok4! z29ooyc?)u96ZovCFTu!AGq0Fq_j-~`v)_3L20n8)0u+_IHrdfwdU27?xg~X3Alf}K z{ulX~+M)c6Re5(~`3-m@95#CcTNXYdgcQoemalKkO{k13H5A=T64?0Q_T^g-qWGOW zh2P~TqSo5t+cW2T$q{F#Ul~nv6x_eA3|@*v2A>au>Jq3ZU+zG^ne%XtH98m3vdNs- zOZ~rSk8WYE-g1l8ovuY#BdRk-@w-(N{WJ$z=LD&yPs6hwX;6LJ93m}xHS1$Td$h@b z%rE04TvS52d85aNd=c@iea9}f-gS6Rv>!##HTXlv#apb$nvgen~^;~bOs~=b8^PWkPm`b&$ ziCLV)Z)UNfZ`8;ls;L;2yOwRrZtK*MD8Hh`{ge~q43{XxwULh#m^C)413$)8w+o1d z2Bkx>U&P(r`zSPZaoJGqX8f#B+n(*7nn&QOOaE?N46HO8W#{pz0lp=&10R0Lw>>5r z>%&~JY6pK1E;z4hMllD;IqXo34RvhZNilcMin+Y`VaxZ#@~BE$opou+BhC-CJ^PZU zTY60tk4=BPF^ta#E5ZkB&P8r!t6?sYP|EJ@!5&Z{S2y!E*tIWgnGG5bp=7X}#Yg_{ zOWoptv&jDoXOVWWaS8McFCGFv&_8H?iYz0lUCX-{j^gNXL$IE|K7{Q7_-@tMW7T*y zL2_-OmI^jp;|BzwP3!AuHn=Ox`rLV(2-rHWcaa63yY9|b1po*yxMbcZ5C+UCrEHQw zXAMPWf0>JF#oo@`Of^nQ6~6oYF(!@tFM{NtI=>t*QKhkJvjIMw=YM*ks?#FJVT#Wn z^R_n!J=k3=5`^1a1{>4sna!VUim*8T8w7bV^FFj=J#;2|egX%r0c-Wm4#eopraEjM zHDD-f$e%5Q?@J6*7W=Vuw6^x35WwAY)-HY>O*Vq$diwcKg#fvAg@o_Y+Hab~rZ=sR zH&DASw9Z1f81h@qx$Xc7PX7sIJ%#?O`mfr!Hd8S1 zzL$+;AJfoQuqknVF@pg@vh`j*XD0F9hAVaIIii4(t4Wy(Qc0Wil&1EcgcjsUh>_Df zH*}4pb7GV^-9(!8kNU18)z?W&jbogTZuiVe1v_OQ=oY_wTe%DD+J#}#izkZR03s`M z^w*n=9I2D8NJOS+u9AEWO`xr=K&NWg^onK>Gm83z4h1D?ZYFK#yK|V_zJ5eCN)}-Z zrX2>5lD)q|84NTR@rj$(wJtiMA3q&izFU%?&~R#Mv0h7I>vXL9OVq?v;0Jx+RErSA zzOfO(J#rL^kfviu-DfPa+c7Q@jugOW#V}c?NMKHIT``pxwE|i_(WXBprd)~{=>fqk zrP43o%r)WN^nA76x7{w*luB7g-UMgVmhWhl3RplURaPdqx#$~7dPdoT5{6^F-dhaU z>A1P_x3U)#BG`L*JF_Oa7{@LK7uu9E(Hz04bi{(fdQGOFNf1re1pOLXkc)AFpM$KK zBd-xPc4RcVq*iWfXK}vMKN8hD(uP~6yOcirT=u?NPjEo5cL-1g!uirCqim!&DT9$y zUlMdi2#Ywa$0Lwxsal#Mt0HIn17?c-*L!&jc$J8A_cS?yR?q-;m88&+7QB_0_iqKq zwlclV2!4xGT%;ek6{9^%I7?squVycQ5@yPkTOU(Fq8GD!R@~c}B+sJePfwI2oUVcG z3bY)|{S7JsKWA(To90~GWTC5mc(j+Z?ZE7*)6c3Yd7?G|cTJcvyM5OLZN9H(vr>sa zr#*}QA`%0d)$dqFzcSN!=h$-5y!SYNb3@HigSpL&lc!TU>_K6BJX((7TE4?HX$RcN zZY!502?Uy)>v@`CC8=uV*L^g1Dn`%*JdCe&yd(GpVrZLgymGB6)KvlD2i(e7-2+;A zYQWqG6)m*wLsQX^%(>+RU;>2$Id=bYm>YTJ&5;%78IdCvg_=Q1+S>5=*Au%tTT=J$ zpDWdUuXpzgKWnsr<9wxH()3tLxqjU~r&}X7T4}qrX!L!{w&4V2fOt?ybTlkJDin;S zgF7kleGQkNeScQG+DJev3C)YU?TQ+M#VXQAIdoR0d9w!br0N`XJlwyjCwJ9WH?j2z zE++`SB6v1};Wq2NqzyK@OTGHFZ#YxN2_s9YY(^5Dzvd|o{D1PXgG=^cYq52eGz6C_ zPaQn#N+SE*jK^(r?eHq~98EjX^DE?t_P|OT&_-<7!RH-9@?*!AHUJ-iGu--TCWZ${ zjQ{{mSRfHfu}IlQld-*YCUpDNC{oPECt5b!|END&1=Vt_+$IB8um@7YlVLvSL z{>6p~(tQuShQUd^iU}W=eC0a7*LHY}X>QGjL{5%HT7Y4|sp_F)U?1T>r#r28F8CF8 zp1nk`7M-N><`~~PBTV6uuiFrt2z9)iV7y%U9mfuq&=oR+^7G0N^rp-=C4|N+VYuC} zJ(b=vpK@|G#rla8jq)i6vIN4(b?MJey9~5;t21EW&=S3 z|2;R4yfYo|KB7+=W-@n<0bbM7d&F{U)bbcl?{DuYShQGeWMtpluV#HgYX*gsa0;6& z2ifBW<%TP1(MwW-&05rzBMBd^9?gAxjL#-llOavX(5u63(U(Mrq)EjPr2eZLtr9YK z7E^z%lh2oap3AR0svv4Ed~pHV{G{@p`raKk=5J0$+;PT6(XeI+NBGtn{S4Zt^W2zs?#pq2?IpauXqdCCmt^beN2tyWLf_a*DT$ae{mXA67u z%q*cQxJq{@w5Ui1H>lWUOJwvI{0cfN>L$msg5&KkFE45r#@h+yukX;)N=BA2pZv%n zryqhSc^Q8e`e^K7^zQU=7#MFEVS4XFmC}eJna*c?N9#iB`Qi1t^Sa|YX0K)qj0(K) zUg+%9=$IAQzZs&-o%McGq;7aR>(}RSbpHilEvx bh%`xG1ZrgN}&2%NA4Bx&ni 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 z z(7Q%Yj>Hk2o{g>ht!-^7>4?<1)%LD#P3-)1q|a^eXT6i|GUbG1SZq?Eb7kdl!#vR| zKz@3<{~pPE?~${-s?lsRk@VouT`&;7h)Nn>)Pmg2FD#TxQm%ZY2Ay_?t;YGF^+J4JUd)tV6>hC1_b-d?c8NZi z+VN1Thwo;0Tz_I_+0;hf;CEHtsdzh>B!Z|9FY&1;WWI`%;ZvIjVR&hY(FR!HW!?0i zt(IjT&S6vHDOjI$+|=@LDmNc`YYS%Vj2TR53<#vV=eFj(u18HA!DfT*2_IMKJLgiD ziJ*=-le8+kmG9=Jl268jVb$uClJ!~3WSgO|0u-502 zO@Xb@8OG3OVd-{OFEt2qIlEgom}?4#Z3L=H*cx#IS=*SYymd9kye4(In55$Eg$WfY zh6oe7hdXQJ{fnhKT%l);pF?FXxvx{NM&7EQ!x1}8tp0B6k1@q+hcrvRjI^yi`ixKxe4EGsUV`0L<+7D zM${cfc@&y6MHCi@qbd7f0Y-$lXwe^UASyPy_I4oIxrBt{gv}pMQ42Notc=g&=0CH* zf7aA9YkaJm=rIaj#Tv?@Zu`ouLHK5ouz$u###Py6lW!g_4AkQ6&`7_G6oiv!No$=< z;_z~y=qAG!2#kL>=Z2598CnF)d6pd=%PTgk(GhVP#uOU%W3Jxt0+ z;oWy6uAB!+4}0w~rm4MOSY90Yl(Xv)%*^~`VHx?8AG__h`Ri&t2SnDq$mL_dwo*3| zhJ9i?d1M+G$)so|`(PpY$c zR!^v$R>Nm$Ke1(zHQFApdXlrTsdxt@PmM{$cZ}v66b_eJtHjIIPU>)feWj2+lui0-an*aj=DwuGd_Jt_f+UoBEj6K2bhDvg zZUmN*Sxx^y3E{EO6&v&K_LBCMV)nLdtJC*`3GTBY?OsSSN6cZ3?ANbj;^X6s%w!YN z1ys+==;dP!?8IZXBQ~W%v^kFEUA>m{EQ#VPoakQU{Xtf}1eLr^Q4*_%XuN`cHDu`q zD>PZE3u%?;iV#@Ono}@qFH=W0=nEd24;#qHqeawlqlAxfzOy;Ro%sP==p1kN&N~eQ zgNsS0ew3GEUJ>scm_j(T>?@u*pCBoju=gkPzAM9IFJ5nyixFVU$6Il@-**+~Olt3B z9ZVf~t78H8Qsg|<{_eUseSU!yRh`Th<}Wd#^vrJ)Iblb2&hhb)svR)VL}S+-C@BV9 zf17;g!!9t_-gNVTs^EH648s$}STDV~A0bWtB6a!7vuBbdwUdNcjiVDbGj|)xMR$EG zgxdS4#<$jeyZHs!5-iY#>+NqckIA?>oJoR9mPqs7pTx%()d!SrM+kB+`5%jK=@Q}` z8aFi?9tuHq+r&J)$!dBqLb`}p$sFr1VjX3S>Rj(%jy-T}d#+4{O_+7f-TI)7?YRRj zZqcfNde$z=gxH6VkI6jcp3=A8v9h%~lf>7ZfxLx>jzbQ}c$Nw&L~jZup$og)M+BuN zeAqx&i^l5|_<-bohfB7xITHTROv}J*+v(I1#n^UKgKO3_bAo;lRfV)%dOLMk>!{6# zyhxWkrxygHplZ7@YbdXEj>)S*9(l>Dvh{vPPj_68*>34bCUIvD1StD2WEu`e2Rr7E zgVHZ>q2<~23$X$UZI=+CPuPMr&3#xcibFzTgD=GUtwRH=ZRlA}OTdlF=vRLmwkEKu z)nE4IAG&zNPMq}Ds4$n!rcy9$b>)I=>};2+21a(Hi~|qPMD`ZyP!mk<^33>|4wDJJ z9+$(urP*jmufKlP6JU0o6|Td z-70-4t(^_Y9*MWqj$zA_kcG&3n;$Sz_$8T7A&sqY`gH6wVzEN$9n|X5>vW!?^G4E~ ztiG{LtDw6BV4S$ zDx!mRQ(U}m{7Lyr{!Rz&`bU-yZ!nvvwpK%@Uu#_Zpq>}*HL2)@?})d$*I8`QkX&3` zjKP6P*R=d7xqJaz!l6Frzu0c|-Gif8>bsly0UN4ihp)2=qo@XDaT60lPtJpm&(-mj z!$2kFycci9r25fj(w06=wPN2lad|%S=^-T9_Yl&om=gQPQvizQDfV==+pl$vK5p{Z zyQbb@fH1WcQkw`C?ZVnAXZLcbS<>CD_=cf8 z_(s$$IghSLoJmVn0?^d?GCRk9R-9~t<&ov&w)KtoGm8@Tv02)L)eqv#=HCByUL2Do z{q<=Hk7iFgTCT-*?*lKgz5{4>>zafRdRF=-u6^299>+-mpGa+QYiZv5$b%^TFRMTm zLS7*~KV(FSI0hHs_pj4QOX;c^?u13bijX?#r_p+$5>f+(W+Ma2WZ#P=$RgY_)g>3> zuj+>0ty)OOYeYjd$j_b)IO_rc=07Yc71yYV-8xt6j#n$Iur(~P&@{wG(sv!fqv@6% zLl;9(79u&sGNJJEEV5=qe)gQT_d+e=EWAZK%cdrY<0EBJ?E4Bkr3=$Q&Foepu{`nPk3?U!-VX()4 zSPvleX-+;q7r$Be+%_QzX=(GqXz2+}@Mm4)clfS!Sh1#EaqaMk9k~ESTm?W)+dj66 zIoYh_lp!q<#@pRpc0QCR<4}&+K<)~)_&l_&FYZv9jVdKH6yR~Yjtc;SEgyCnx>Lz$ z8auW~t7=Im_a$cXkJnsNOkZ8~FrL8Hh8M{z#2=2fbzHo)DF$);zUh*^x33{U`QlPz z8J*=Wxv7nZBah@M$(7UJ8QmTG1Ws|*)|?XJO!wBA8mA@`U)gDnIu9Egm%*Q;_mFC# z?PXmsELxWNhc64O$86visEHDiit+eeZm%>a_l(GSCmcNd2{A`Du^k(e*9!fc0l8(h z$mOjtp&`WPZo$dMZi~iLJjT+A0ZkjH8qMd>?gfRgj5cNRJctTHG!QsYCLSDb5(g84 zk@I6Vt+>(nGOJe!**gSw~b%u#P&9s(bANfuEcZPERs<;B8Yhm?S0TZQqPaAkTM9 zJS*dwYaCh|-fNnrF{1Khkv?HyoJi^FV-ynzt~`=JIzL>*ifmx@-7C zoNkKVwNwqK<0}=lXl0UV4SedkCjI;AAEfV#nVXN-)EjB-7P=+XRX~s_q1S zuJzk@lw0pk@;Y%(*k@79OQ-zjxI3PWhs#J?@HGr6jD4uHHy@@B(6ZlaVh!4iAl@WM z&tgq388kA70AGSKe#nLVfKBNu+k>*K`Xq_JkTASQj z=KNp2qYIyBPqt6o=)Xp6e-_w%iLnE3W&~006`R)?h#c@~+FTl4pA2R{*$FeNanCUt zd0l8&l3Q2^Bas1Q9V}|zU8Il}T68F<{a+h>{i2zqxOV#@U!ac2f!?MLVxzuDP8IpF z)psnD>G^sST1b=bC|-b{^3s)j7n->CruF|hT}Wn~VLrG227`Zr>SK)zs1c`@6L%Ei za4{Z=Y{jKZ7^}sIfN5hEsUV|kTAk_E5IM-5&|{;$Tb*lKa%Z${s=~~U}V~n#36{qOh9glUw+onp=JPZfcu=0UyMNf%xWvl1ht#{&Rn?whnxwwos)%sR?O9xsSA(4eym^!k;cD}jaISO&Ks5KtuXBp3R! zL`+w7F4^@wz!jSYNvB@%>A=%5_FadtsHe?2O&Ui5G}dYV^}4uB_1``+$?ofI)e21` zA^n_A*``15MoWF8d4$#7QSyS^QzD=uZa`~>LU>N6YAD;Stv!?!LYBs1A!*u&l*PrL zcA+SgWaqL!fU0`xh+_DZi$s#mWN*_(wh$}(5rL#GoZ5bLBs=2t?I84TLvMclNqKNU z*f>>NT;_hPTNJ6Ma)asCt#=pLnHCD^kLOzZWdGrdje ze%B>43{zr}nvV6vld~%QrWbSch``(P-mF8g$}^BTH_1*6K0>mENnDe8`Zz-hZdeqO zZoNH~>=vC5hZUVHi0mpVVk?AZ{SccT947wMCaCPSU7*)t8}Nip*&AL5@z|&@E!Smk z%ehHsX!|Z;nGfg1w9{GUIB_{>3Z7rC;@j1gZpBlTaR42+MT2whCYaRWt`tT;tpBT@ zJ`|@T91(2hB20B|o5hMn1&Kt3GBNB632>qX5Xg`&6@$R2sB56{yp*`^!n}!WY;)vl zq`LjuTL#FN8H$~GI~UP|jWWlw3E$7#G&1oIq^%wDDB|H_RSywYK^H-3i8CZhx>&hTkYXls1{R?mL^b?we=Cd@!=10RH7i zOL(j$$*+4`tuWU{PpKxZFo%Z|IS2N`+ z&#S+k#eFmb56%dy#Fi7*wcI%O`skwrgPLXF;qvY7UZ1u5r_ny4L9yjR_G@mdjKWkb zEHA9b(=8DI6|Bz1g_CO6HUM+S4d>>=MlpWPt8x!HQsEc6whUSh&5^~G;hJZM@LiXP1zvMU$b_}UlnZ%cH?5oJv=PBB*$E>Ur%Ho*=B2|xc%JTQh@h9BKHSykN4mQ`iF%WYa0|+7=rY-I1g*kM^+vkKF@(Xa2 z+XHUjPoGOs7MR#5X>fboaujLBI(noV7tLMQ3<{w7V;*$^=5RDfih0$}Jy6+yd||iCP>BZvy9k!{2LKn^Xb%-i~+K9$NwtOXwpH#kTyw!dpy! z7N)cPG1iLn!BK-CGQ#RYLxc8lbru$~M$Vdd0S4;5IP^?FhP2aO*Zaw81p&e{v#0)j zCh!Xi`NAva*lY7Zd{=Y&TPUH5yBqyxb>0Z0L(7krMym)-tb8H~*y`T|c81t_BDfQT zaTs-&w15rI*-+TDDLZ&QY$0$aQ%{kSZFJ~Ga6B%s&`8aNcnsX*8wK6``ZOpqnODwR zKvhctzBU*v`Jx;Itb^bh8W~$7k@>)^(>rJK4NRF)ViFwxJ`?yyxUxDBz>ww(@oE=9 zK8xN(3^Wp@VE;N!z_=qxp&RD!o?Gd9bJ~v^AuW>Do=$Potg^_ejBdVHGiHS=OfGKN zS%P%Ujv9&!foEViOq^hHOR3D0r zPl~Wn%xcj5svop{M9a=5H)x_Gho>;Dx~FfU#IBKsW+QU+SDCO#8I6q;7*L%vR}V|@ zx9>Rku^YNC>NvT?KYmC#OClHtwmQ1+rY!9@t>xhl&_JHV$gQd;6h5c#lev!03_*3g z&)@)k95Ucu#wH_wEh(aF27dsi1-*Nr1SOksIKsvTkZ~lER&*PD4-Ir1;-vp+Pb&y(_->bCV{V*llcRTH+t!GK`NSqFgEN4f$@>KdpiP}<%ZHU{`1t(kFQg#3SwM|+K-6$Od zblAMPnXs_2aZ-Mux}#0*AO`6v7{)!#o!B!I(k^fy%)L~o9bo9R65hJvuZjqkM~?vN zIM3s**O;~V+BlL`YCf{3at{bsTsgWXNS$~s7q|}`mqY#-DphaLZY(`$ZyNCf2GKLF^GlqHa>@)RakG=$B{~s0sj_*H&Me$!& z2dBaw&DpU1uXVgPxC&hZ}VSyU2_NeFW)AW0?@GiXy0M@T1nMqkdG z38F>qaYMJEhL`O90xq(V@R1qY2JsRKL085LiMc=*CBKF_{ur22++<={Y<3}`a&aD0 zI5%IrirfD0LgSj2DCC-FkLYsGv$C>?NZ$747Oc3T=;^pPz*)JGpG8DQ0{UtF?@(^@ ztW;_HIteororVt=CDOO`B&221SFtDANPvZk#B93Rz{LTz6~gZv4YcD^sZNzlP5n(; zX^thr#Js0xJTM<`V?H;UCX&F`Zs2u^BauwV*X*kR*imrdv5}Xv_;i2BtAa?_SdjU! zoOnXnv%tAamn27r&Ed3{fVOFqP*j}QkY|7~J7?!w13njdpDeazcRREsR#3ul+|jAr zAA`d)|2xEnW;|nq zQ=&D)CYlTWl0NHm&P;4FR3Q2d43IspWXGP!XaK~JB0fZs(|5|BE*3;<^DL)#!xm;G zH@{GE5EtIBk+sTRmtsrKYhzUY<_+P|E*oCb`Zyw|!`7OvW&s;MV$kEy*8WycbS0%B zK&G=Wx?X~cvVgy3@^LL#H}y|a@w{{WU-nkt2VxHq-@pJ7IgjJ!=58-G)AGSmq6#*M zCPz9c-Xy|WWZXS{Lqn%YtIqX)3KwS0s_*I(cVZ(=q#ny7AjuGkA%zbX1#36$AoLr9 zWA6Hh=AupKRvpK;bqyxtg;E`Bx5zE%PpPrfd%JCz=spH{`burK*+Pj^oE#J|L;$dh99AG_p+Vc>{vGTc%RoVA>EM zbQ7^~m*uFT{|WrUn@n_70OT86^S2J<uQX%raF8$ZWw?o6=jR~WnabfIG+Z&Rq8;GD@_ELKMmv)>%MFK8OkF?OGU z2x;S~4DzjRZAoUoCtv^5x$~aJ!hiW*Vxsne;FkM;E}{MraQg8JV<^2bmzOa+f`qk* zyK*?<8Wq?Tw=B!*TYp0%*47%pUH&^BvFdcuS82d{Qw3`Jt+W7?T1^Mw@PQWgqLBnB zG_iO5S+)C*Fz#o>+`Pm#c!@_VUr%jrB_vlT-vG9=Bomt>(5}<7c+X-qRstEq&4nFx zXt+D4u{jV8t0i$E19`MM@t|H5#&ZdYzC8waYiO@sfV`WMBsi9SLN*zA@r7e6sR z{82{~Ss&D2PL@xm?MqQCymL|qVaPL%mR9HZVe|1KJwi!5_3hW%{OnCB>kis6Ipp%E zv+E`ihK@#l3MGIB3)7nUHS?hj4O2hiIr%Wr@b%hS)2u|YV+B!D@1#VAjirx^TTY17 z7k2OD6hHHUr`dEe@Oy9)&+~WM(F!I;GEWnMivuX-KSud$%x7`=O8|(gk^1i8*taEI z(;ULbz^ypYihjOm$bCTO=}lJuzI6w9%y#`qYLS!GbYRtGrQ`{qNBRkYZOdkLq=pwM z>h_xhUpG^#JH3mUEXZJjE$+HNN<$20p9^%{*8ix#vFrFtTht`o8wWYz!%fR1BIcPXBvY6 z-PI3-3k~5g5Pk?tAYC3ub`8|lCiKj>S_e$`CDq@iJ~ukLDNy4oo3)O##!5cGH0S;U z^Tv%6T4--FQS*v*dn!FlP8gq|FyvlXF^#vj)t?#Szpw^8UtLYe`-zdNU;R`hlmN4w zGve@T4w(bWs&RC1P(3G3&42!(?!U&8GYaKKL>riAd8AD)&4PIoAI-kRp0chPzts=_ zFNB#r-ps=3EN?hKSs~db22Cabn5;6U4Ci^6!L0Gw&(28r&My5r$(*o1Ix_U$(>;?O zB{T7P-B0&M#RDxNK$yK8O!9W0ogP6mB;r_!d^$5!T|U5I#2DXDS*P~HGkn&xdNMSl z6s4bsg@-Y_kXIdm#_nlf3a}a`mAAjoTfhGsI*r*Ej97j-ney#B!ze*}G$HLb(yyyw zKx$H~{d!6!ab{IMop^Xx&|bS|X8Ob3y{}$xyZB|gcF_b_fRIq`0+3)~4#S#gMt!f8 zT9(&@Z0{5Rr*NIc zWtkgJwTHOteoF%E!u?gj;`QGU8VK(q{mT>FMx(cWj~yx33-3S5q@0NbXw9~mKb^y{{}7G7%TOfsL)U-0H$M&pO2b+sU&t)cxH zCPHH)y{_fRHKZ?ahh1kH6sJj} zj$Q}xy6v#(e$t(%0A%-_Iv|s}Jd8Dos1(`Pb_IL(v|Vd&J34kjySGYs*I3JIe7nl( z6GJ$Na@=c{rSfCcK^Q=YfS8+D!o)XFDdj5@l+ru^wpIJauUZwq+7o-n)rt&yHtr+J z7iZy-lSLuL4Z}cSB8@J07Sm>Xi;UNOhFV|WJvQO=e%=DfvI5{^cLvh|{lL^)1m}1F4M~pTLK>qYt8bM07z1elhq9;)QZJ9Gn_LB<12Cx>h z$$!Iu2XS|q`M9|>Jh9=k5&7Xtn$@laYIU03Sp>c^9biqB?lY@(TAo4fjk)Dxz=1(a2;9<|9kk0zCiMw0Y%zWWo!+ypB$J$y=0?1_L>fW&!2^6MrPGOp zIJ8*t?l~MCx|)ifj^uGV+ULBme^zbAS*IiU2o5=7ySwdO?{i(33<9Xp>z*fdw#|fa zPPm@o7Ct;Xnr_a@`f%?KAv|9enm8D8j~s8bw}iKq&L^gXS8Vo z1n#Q)6#E))DHyR~7Wnmgho++`9y|A!zK3sRwd}mSX*w=NajE&Y-8K7&d(-z3((0Fr z>Bbd)n1x9fIg2r5gX#`BERgp3^yYPj&2d(d*pCdY1# zLrVue(I09GvD!g9k>lGbN}{2V7JmfFd+^{Rc&w6>i5%{Jv>ojzw6?avRdTFKgJ zsgjmy42o9bEn+on2;6R7hsU@S1-)6cBTRJh<(5<#b&mKud+xH^tx2N-WRi6IhLwdvy0l_1~lJ!F7`Sol79Z(yVxEBfkc0ExN{395Li+x^fmy$4-v z-U%f6VHcroZ`#YCF;*qT_z=)+WaQY&)}xXjB~-PyC|#Xq&bZL5LRAZH!pXqPv8ZiZ zxZh|;i((sTZ}1zPH#*Q*s(638O@@NS3&0bzRB*TCG<6}nH1O7BY7Hb}`d#+L-qIgEGS^VwytHHl`Jt}90^E2h&jkXP*7vKHW8 zm*JE!(fO2o$O4h;;Uue4PyV_lFLTO;+y*ZQ+d?6Z7DW3H9V@bd7wZYC%P(}4!1X|O zew3jRgHqYomQu}k?zji|I0bxQyv`0{)OkzVRf0z&Jv!wELNm%`v-LfOG~UMDWR^mZ ziuM=_x)>NvBBqWHC}Zi+MtXQg4l3WGZmDX$O&d!@I`bi6qC2X)vUPMpct2l4kqyYq*DJtq z!Toz|E~vf+N_R3AWOO)9g|?EUOk9j)V--l#$<7VRPJChg?lmoqhLYy~OA^P)5Hpw8 z!z5`_COh*YYb@8&>4Lq*b2CGK_}TV1KV4)HxW?@rIo5NkF?umK<4v4w1^JWE4lcSY zUQ@1W!Lgi4`59I27uujg#pN56!lV-V`dezt2IAy{BPXjW3ownQ4)V=iw}3rhqB)+Y zPH)uI*6KyjtzBP(j4RSLv`<>4XYzj&+s&P)un1p$azwp^0OH2U-p8re zt^&hGGB)L?{K}e|fra8wfO2f)X2atStJ&DYi_4K|rBH^EL|{(l{*MP#XE}sngNAL_?SO7W z>J?sS`&;F}awB&#>X^^^c3cYN%b5rN;{dL^7;YDf9CdSHUgL5c;OG@(yz?hokPK^i z(g^vjvK`diT;1Ni`bwkQoQB174RO~WS^Z6C1CO$f<`?;_5Ugw@ZE@uaqu9lH-ADV!@fsQ1>y@5y0Fgg$M-eN$<7z2ipJl& z6n!PV?q;X8mtVHgw%5(q?fc6P@lK_s!e^J{-#6_iSghFE92Xe#V#Iz%G&KF5Ji~Cn zy}MOTKRb;+GCP$$9gKvpiqU=sX&Q_JaH9B?J{;XzEJr`d`IU--N%_&aedk&@_M3TQ!Y;q^Ml|f`SaSEosvT_XbOwPJwef-N zv%)I(pI^|Y%#AS*CBDEj>=CqjquJuE%LLlThw>}ecZtzG_~s91eY(&RlsA0%vGKa8 zg#AbLz<;6HL#AP?A;4vIL*ZMaxiMxX(m8r`n+?waWj`{6rz zAdG%F?eH#jhni_a;cG1V4%@JDw`X^o{k8vmxkf;F{!ToSqoO$ z`XTD&&I+ISZXpprDR_1St&*brQ*mPkg(@!%)-3;wBUNj}S=zaM-{W!`JN;}wXGc|I z%#1Lu<94n`*1e_XRBD#eyN%uw!?xptvV~Fwbong!<+Mx)R)tIJe4gQf3<#bzFP6SF zY`?vT&5PmN@$CuR>edL5iYMY>GNc9c+Hi6uLhNzLZuB;^Y;Y#!Sma-y>26PGC#!hT zE-G!c1(V1-4LkF!^vu(7f$4B*GANF|H>h0E1FhGCgdwUi0q1dCYC$V}^9>rLsDfiE z&_Ycir`wPYt1Rl`L)Nu7E81&?@SPWpTgX&Ov}=WAyich+(%pZ{Y&txsTAstmsTL+{u>08 zfYY=GaBS~3$gOAyTD#HvBFg!wsUp=&OY&{H-%7c&&15JkeNijiFJ{It$+_#fmXcXIH+RmHr$esr+-$7JCv+zyAvNwi(@dneWs1NZ;CCP-+L> z+3Qk_UZR);>8KC7&%o0Xub8^}dkn^FmeHpx1yAol$0G2jsbtSN^$#h>7?1GvH8VJbUH&O6O@4b&IyPV(N6-s;s&s!6ybuKX^KmDy zqP@oyy%*c<;32!E`?jyf?9<>=0h`12Eo*^`1kS)oPg)y$sy0q#$ zu?l9B7uNeZzayV&qkK*edmvWrV)voEhx0)DE+Ctj^UI$N>zFh8-sI)A_svQ5`G>5h ze@v)8ph#UTx4N)OBPK^q(3LIxRzU>@=E=vpz!h13PUgD#uNn=-nF5qV3?kR8Us>nvO@!uC>wi%=SPd3rkGEjy7F8p%B$gG`FA$@X=#GWxScx?5wsK z)0`wxRU1uVzOW!g`cl7kICHWGM)Hw7)UMM+at0c{UBZVa2T|wpl6G5KcKGO{=k4Cw{;nT#Pz zz`X#?bp!fnVD?oPDyuFp; z(%8KSxSbSufBj+RNg%iOIt@SXQob_^rLBYe+;+(btgJP-7{5kmtxY&*{xt6Idrm6V NpS6A}zG3<3{{Y>jcB%jX literal 0 HcmV?d00001 From a5e5e78c4ebf1d75f62408871c389a455e65a279 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 12 Apr 2026 21:58:45 -0400 Subject: [PATCH 18/20] refactor(geometry): deduplicate axis branches in SpatialQuery.OneWayDistance Merge the near-identical Left/Right and Up/Down pruning loops into a single loop that selects the perpendicular axis via IsHorizontalDirection(). Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Geometry/SpatialQuery.cs | 63 +++++++++++--------------- 1 file changed, 26 insertions(+), 37 deletions(-) diff --git a/OpenNest.Core/Geometry/SpatialQuery.cs b/OpenNest.Core/Geometry/SpatialQuery.cs index df25865..cded1a9 100644 --- a/OpenNest.Core/Geometry/SpatialQuery.cs +++ b/OpenNest.Core/Geometry/SpatialQuery.cs @@ -306,49 +306,38 @@ namespace OpenNest.Geometry var minDist = double.MaxValue; var vx = vertex.X; var vy = vertex.Y; + var horizontal = IsHorizontalDirection(direction); - // Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary. - if (direction == PushDirection.Left || direction == PushDirection.Right) + // Pruning: edges are sorted by their perpendicular min-coordinate. + // For horizontal push, prune by Y range; for vertical push, prune by X range. + for (var i = 0; i < edges.Length; i++) { - for (var i = 0; i < edges.Length; i++) + var e1 = edges[i].start + edgeOffset; + var e2 = edges[i].end + edgeOffset; + + double perpValue, edgeMin, edgeMax; + if (horizontal) { - var e1 = edges[i].start + edgeOffset; - var e2 = edges[i].end + edgeOffset; - - var minY = e1.Y < e2.Y ? e1.Y : e2.Y; - var maxY = e1.Y > e2.Y ? e1.Y : e2.Y; - - // Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY. - if (vy < minY - Tolerance.Epsilon) - break; - - if (vy > maxY + Tolerance.Epsilon) - continue; - - var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction); - if (d < minDist) minDist = d; + perpValue = vy; + edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y; + edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y; } - } - else // Up/Down - { - for (var i = 0; i < edges.Length; i++) + else { - var e1 = edges[i].start + edgeOffset; - var e2 = edges[i].end + edgeOffset; - - var minX = e1.X < e2.X ? e1.X : e2.X; - var maxX = e1.X > e2.X ? e1.X : e2.X; - - // Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX. - if (vx < minX - Tolerance.Epsilon) - break; - - if (vx > maxX + Tolerance.Epsilon) - continue; - - var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction); - if (d < minDist) minDist = d; + perpValue = vx; + edgeMin = e1.X < e2.X ? e1.X : e2.X; + edgeMax = e1.X > e2.X ? e1.X : e2.X; } + + // Since edges are sorted by edgeMin, if perpValue < edgeMin, all subsequent edges are also past. + if (perpValue < edgeMin - Tolerance.Epsilon) + break; + + if (perpValue > edgeMax + Tolerance.Epsilon) + continue; + + var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction); + if (d < minDist) minDist = d; } return minDist; From 838a247ef93262184dfb25cae6a13311768d71a1 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 12 Apr 2026 22:33:48 -0400 Subject: [PATCH 19/20] fix(geometry): replace closest-point heuristic with analytical arc-to-line directional distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArcToLineClosestDistance used geometric closest-point as a proxy for directional push distance, which are fundamentally different queries. The heuristic could overestimate the safe push distance when an arc faces an inclined line, causing the Compactor to over-push parts into overlapping positions. Replace with analytical computation: for each arc/line pair, solve dt/dθ = 0 to find the two critical angles where the directional distance is stationary, evaluate both (if within the arc's angular span), and fire a ray to verify the hit is within the line segment. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Geometry/SpatialQuery.cs | 49 ++++++++++++++---- OpenNest.Tests/Fill/CompactorTests.cs | 70 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/OpenNest.Core/Geometry/SpatialQuery.cs b/OpenNest.Core/Geometry/SpatialQuery.cs index cded1a9..c7bac71 100644 --- a/OpenNest.Core/Geometry/SpatialQuery.cs +++ b/OpenNest.Core/Geometry/SpatialQuery.cs @@ -631,19 +631,46 @@ namespace OpenNest.Geometry { for (var i = 0; i < arcEntities.Count; i++) { - if (arcEntities[i] is Arc arc) + if (arcEntities[i] is not Arc arc) + continue; + + var cx = arc.Center.X; + var cy = arc.Center.Y; + var r = arc.Radius; + + for (var j = 0; j < lineEntities.Count; j++) { - for (var j = 0; j < lineEntities.Count; j++) + if (lineEntities[j] is not Line line) + continue; + + var p1x = line.pt1.X; + var p1y = line.pt1.Y; + var ex = line.pt2.X - p1x; + var ey = line.pt2.Y - p1y; + + var det = ex * dirY - ey * dirX; + if (System.Math.Abs(det) < Tolerance.Epsilon) + continue; + + // The directional distance from an arc point at angle θ to the + // line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det. + // dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π. + var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey)); + var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI); + + for (var k = 0; k < 2; k++) { - if (lineEntities[j] is Line line) - { - var linePt = line.ClosestPointTo(arc.Center); - var arcPt = arc.ClosestPointTo(linePt); - var d = RayEdgeDistance(arcPt.X, arcPt.Y, - line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y, - dirX, dirY); - if (d < minDist) { minDist = d; if (d <= 0) return 0; } - } + var theta = k == 0 ? theta1 : theta2; + + if (!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed)) + continue; + + var qx = cx + r * System.Math.Cos(theta); + var qy = cy + r * System.Math.Sin(theta); + + var d = RayEdgeDistance(qx, qy, p1x, p1y, line.pt2.X, line.pt2.Y, + dirX, dirY); + if (d < minDist) { minDist = d; if (d <= 0) return 0; } } } } diff --git a/OpenNest.Tests/Fill/CompactorTests.cs b/OpenNest.Tests/Fill/CompactorTests.cs index 21e30b4..4c27df8 100644 --- a/OpenNest.Tests/Fill/CompactorTests.cs +++ b/OpenNest.Tests/Fill/CompactorTests.cs @@ -8,6 +8,76 @@ namespace OpenNest.Tests.Fill { public class CompactorTests { + [Fact] + public void DirectionalDistance_ArcVsInclinedLine_DoesNotOverPush() + { + // Arc (top semicircle) pushed upward toward a 45° inclined line. + // The critical angle on the arc gives a shorter distance than any + // sampled vertex (endpoints + cardinal extremes). + var arc = new Arc(5, 0, 2, 0, System.Math.PI); + var line = new Line(new Vector(3, 4), new Vector(7, 6)); + + var moving = new List { arc }; + var stationary = new List { line }; + var direction = new Vector(0, 1); // push up + + var dist = SpatialQuery.DirectionalDistance(moving, stationary, direction); + + // Move the arc up by the computed distance, then verify no overlap. + // The topmost reachable point on the arc at the critical angle θ ≈ 2.034 + // (between π/2 and π) should just touch the line. + Assert.True(dist < double.MaxValue, "Should find a finite distance"); + Assert.True(dist > 0, "Should be a positive distance"); + + // Verify: after moving, the closest point on the arc should be within + // tolerance of the line, not past it. + var theta = System.Math.Atan2( + line.pt2.X - line.pt1.X, -(line.pt2.Y - line.pt1.Y)); + theta = OpenNest.Math.Angle.NormalizeRad(theta + System.Math.PI); + var qx = arc.Center.X + arc.Radius * System.Math.Cos(theta); + var qy = arc.Center.Y + arc.Radius * System.Math.Sin(theta) + dist; + + // The moved point should be on or just touching the line, not past it. + // Line equation: (y - 4) / (x - 3) = (6 - 4) / (7 - 3) = 0.5 + // y = 0.5x + 2.5 + var lineYAtQx = 0.5 * qx + 2.5; + Assert.True(qy <= lineYAtQx + 0.001, + $"Arc point ({qx:F4}, {qy:F4}) should not be past line (line Y={lineYAtQx:F4} at X={qx:F4}). " + + $"dist={dist:F6}, overshot by {qy - lineYAtQx:F6}"); + } + + [Fact] + public void DirectionalDistance_ArcVsInclinedLine_BetterThanVertexSampling() + { + // Same geometry — verify the analytical Phase 3 finds a shorter + // distance than the Phase 1/2 vertex sampling alone would. + var arc = new Arc(5, 0, 2, 0, System.Math.PI); + var line = new Line(new Vector(3, 4), new Vector(7, 6)); + + // Phase 1/2 vertex-only distance: sample arc endpoints + cardinal extreme. + var vertices = new[] + { + new Vector(7, 0), // arc endpoint θ=0 + new Vector(3, 0), // arc endpoint θ=π + new Vector(5, 2), // cardinal extreme θ=π/2 + }; + + var vertexMin = double.MaxValue; + foreach (var v in vertices) + { + var d = SpatialQuery.RayEdgeDistance(v.X, v.Y, + line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y, 0, 1); + if (d < vertexMin) vertexMin = d; + } + + // Full directional distance (includes Phase 3 arc-to-line). + var moving = new List { arc }; + var stationary = new List { line }; + var fullDist = SpatialQuery.DirectionalDistance(moving, stationary, new Vector(0, 1)); + + Assert.True(fullDist < vertexMin, + $"Full distance ({fullDist:F6}) should be less than vertex-only ({vertexMin:F6})"); + } private static Drawing MakeRectDrawing(double w, double h) { var pgm = new OpenNest.CNC.Program(); From a3ae61d993c82699b2c667822f3d385893ffa58a Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 12 Apr 2026 22:37:56 -0400 Subject: [PATCH 20/20] fix(cutting): emit open contours raw instead of applying lead-in/lead-out Open (non-closed) shapes like scribe lines or partial cuts don't have a meaningful pierce point or closing segment, so applying lead-in/out would produce invalid toolpaths. Skip the lead-in/out logic and emit them as raw contours in both Apply and ApplySingle paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CuttingStrategy/ContourCuttingStrategy.cs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index 6cc402b..d8d360f 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -69,9 +69,17 @@ namespace OpenNest.CNC.CuttingStrategy EmitScribeContours(result, scribeEntities); foreach (var entry in cutoutEntries) - EmitContour(result, entry.Shape, entry.Point, entry.Entity); + { + if (!entry.Shape.IsClosed()) + EmitRawContour(result, entry.Shape); + else + EmitContour(result, entry.Shape, entry.Point, entry.Entity); + } - EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External); + if (!profile.Perimeter.IsClosed()) + EmitRawContour(result, profile.Perimeter); + else + EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External); result.Mode = Mode.Incremental; @@ -99,10 +107,14 @@ namespace OpenNest.CNC.CuttingStrategy // Find the target shape that contains the clicked entity var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity); - // Emit cutouts — only the target gets lead-in/out + // Emit cutouts — only the target gets lead-in/out (skip open contours) foreach (var cutout in profile.Cutouts) { - if (cutout == targetShape) + if (!cutout.IsClosed()) + { + EmitRawContour(result, cutout); + } + else if (cutout == targetShape) { var ct = DetectContourType(cutout); EmitContour(result, cutout, point, matchedEntity, ct); @@ -114,7 +126,11 @@ namespace OpenNest.CNC.CuttingStrategy } // Emit perimeter - if (profile.Perimeter == targetShape) + if (!profile.Perimeter.IsClosed()) + { + EmitRawContour(result, profile.Perimeter); + } + else if (profile.Perimeter == targetShape) { EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External); }