Compare commits
12 Commits
v0.2.0
...
6880dee489
| Author | SHA1 | Date | |
|---|---|---|---|
| 6880dee489 | |||
| 0e45c13515 | |||
| 54def611fa | |||
| b1d094104a | |||
| 9d66b78a11 | |||
| eddbbca7ef | |||
| 4e7b5304a0 | |||
| 06485053fc | |||
| 92a57d33df | |||
| 6adc5b0967 | |||
| d215d02844 | |||
| 57863e16e9 |
@@ -1,6 +1,7 @@
|
|||||||
using OpenNest.Collections;
|
using OpenNest.Collections;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
using OpenNest.Shapes;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -548,6 +549,65 @@ namespace OpenNest
|
|||||||
Rounding.RoundUpToNearest(xExtent, roundingFactor));
|
Rounding.RoundUpToNearest(xExtent, roundingFactor));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sizes the plate using the <see cref="PlateSizes"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the area of the top surface of the plate.
|
/// Gets the area of the top surface of the plate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -3,31 +3,33 @@ using System.Collections.Generic;
|
|||||||
|
|
||||||
namespace OpenNest.Shapes
|
namespace OpenNest.Shapes
|
||||||
{
|
{
|
||||||
public class FlangeShape : ShapeDefinition
|
public class PipeFlangeShape : ShapeDefinition
|
||||||
{
|
{
|
||||||
public double NominalPipeSize { get; set; }
|
|
||||||
public double OD { get; set; }
|
public double OD { get; set; }
|
||||||
public double HoleDiameter { get; set; }
|
public double HoleDiameter { get; set; }
|
||||||
public double HolePatternDiameter { get; set; }
|
public double HolePatternDiameter { get; set; }
|
||||||
public int HoleCount { 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()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
NominalPipeSize = 2;
|
|
||||||
OD = 7.5;
|
OD = 7.5;
|
||||||
HoleDiameter = 0.875;
|
HoleDiameter = 0.875;
|
||||||
HolePatternDiameter = 5.5;
|
HolePatternDiameter = 5.5;
|
||||||
HoleCount = 8;
|
HoleCount = 8;
|
||||||
|
PipeSize = "2";
|
||||||
|
PipeClearance = 0.0625;
|
||||||
|
Blind = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
|
|
||||||
// Outer circle
|
|
||||||
entities.Add(new Circle(0, 0, OD / 2.0));
|
entities.Add(new Circle(0, 0, OD / 2.0));
|
||||||
|
|
||||||
// Bolt holes evenly spaced on the bolt circle
|
|
||||||
var boltCircleRadius = HolePatternDiameter / 2.0;
|
var boltCircleRadius = HolePatternDiameter / 2.0;
|
||||||
var holeRadius = HoleDiameter / 2.0;
|
var holeRadius = HoleDiameter / 2.0;
|
||||||
var angleStep = 2.0 * System.Math.PI / HoleCount;
|
var angleStep = 2.0 * System.Math.PI / HoleCount;
|
||||||
@@ -40,6 +42,12 @@ namespace OpenNest.Shapes
|
|||||||
entities.Add(new Circle(cx, cy, holeRadius));
|
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);
|
return CreateDrawing(entities);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Shapes
|
||||||
|
{
|
||||||
|
public static class PipeSizes
|
||||||
|
{
|
||||||
|
public readonly record struct Entry(string Label, double OuterDiameter);
|
||||||
|
|
||||||
|
public static IReadOnlyList<Entry> All { get; } = new[]
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all pipe sizes whose outer diameter is less than or equal to <paramref name="maxOD"/>.
|
||||||
|
/// The bound is inclusive.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Entry> GetFittingSizes(double maxOD)
|
||||||
|
{
|
||||||
|
foreach (var entry in All)
|
||||||
|
{
|
||||||
|
if (entry.OuterDiameter <= maxOD)
|
||||||
|
{
|
||||||
|
yield return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Shapes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlateSizes
|
||||||
|
{
|
||||||
|
public readonly record struct Entry(string Label, double Width, double Length)
|
||||||
|
{
|
||||||
|
public double Area => Width * Length;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if a part of the given dimensions fits within this entry
|
||||||
|
/// in either orientation.
|
||||||
|
/// </summary>
|
||||||
|
public bool Fits(double width, double length) =>
|
||||||
|
(width <= Width && length <= Length) || (width <= Length && length <= Width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Standard mill sheet sizes (inches), sorted by area ascending.
|
||||||
|
/// Canonical orientation: Width <= Length.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<Entry> 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
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a standard size by label. Case-insensitive.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recommends a plate size for the envelope of the given boxes.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateSizeResult Recommend(IEnumerable<Box> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recommends a plate size for a (width, length) pair.
|
||||||
|
/// Inputs are treated as orientation-independent.
|
||||||
|
/// </summary>
|
||||||
|
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<Entry> BuildCatalog(IReadOnlyList<string> allowedSizes)
|
||||||
|
{
|
||||||
|
if (allowedSizes == null || allowedSizes.Count == 0)
|
||||||
|
return All;
|
||||||
|
|
||||||
|
var result = new List<Entry>(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<Entry> 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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// If the margin-adjusted bounding box fits within MinSheetWidth x MinSheetLength
|
||||||
|
/// the result is snapped to <see cref="SnapIncrement"/> instead of routed to a
|
||||||
|
/// standard sheet. Default 48" x 48".
|
||||||
|
/// </summary>
|
||||||
|
public double MinSheetWidth { get; set; } = 48;
|
||||||
|
public double MinSheetLength { get; set; } = 48;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Increment used for below-threshold rounding and oversize fallback. Default 1".
|
||||||
|
/// </summary>
|
||||||
|
public double SnapIncrement { get; set; } = 1.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extra clearance added to each side of the bounding box before matching.
|
||||||
|
/// </summary>
|
||||||
|
public double Margin { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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").
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> AllowedSizes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tiebreaker when multiple sheets can contain the bounding box.
|
||||||
|
/// </summary>
|
||||||
|
public PlateSizeSelection Selection { get; set; } = PlateSizeSelection.SmallestArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PlateSizeSelection
|
||||||
|
{
|
||||||
|
/// <summary>Pick the cheapest sheet that contains the bbox (smallest area).</summary>
|
||||||
|
SmallestArea,
|
||||||
|
/// <summary>Prefer narrower-width sheets (e.g. 48-wide before 60-wide).</summary>
|
||||||
|
NarrowestFirst,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,12 +32,20 @@ public static class DrawingSplitter
|
|||||||
var regions = BuildClipRegions(sortedLines, bounds);
|
var regions = BuildClipRegions(sortedLines, bounds);
|
||||||
var feature = GetFeature(parameters.Type);
|
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<Drawing>();
|
var results = new List<Drawing>();
|
||||||
var pieceIndex = 1;
|
var pieceIndex = 1;
|
||||||
|
|
||||||
foreach (var region in regions)
|
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)
|
if (pieceEntities.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -47,9 +55,16 @@ public static class DrawingSplitter
|
|||||||
allEntities.AddRange(pieceEntities);
|
allEntities.AddRange(pieceEntities);
|
||||||
allEntities.AddRange(cutoutEntities);
|
allEntities.AddRange(cutoutEntities);
|
||||||
|
|
||||||
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
|
// A single region may yield multiple physically-disjoint pieces when an
|
||||||
results.Add(piece);
|
// interior cutout spans across it. Group the region's entities into
|
||||||
pieceIndex++;
|
// 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;
|
return results;
|
||||||
@@ -218,100 +233,108 @@ public static class DrawingSplitter
|
|||||||
/// and stitching in feature edges. No polygon clipping library needed.
|
/// and stitching in feature edges. No polygon clipping library needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
|
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
|
||||||
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters)
|
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters,
|
||||||
|
List<Polygon> cutoutPolygons)
|
||||||
{
|
{
|
||||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
|
||||||
|
|
||||||
foreach (var entity in perimeter.Entities)
|
foreach (var entity in perimeter.Entities)
|
||||||
{
|
ProcessEntity(entity, region, entities);
|
||||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entities.Count == 0)
|
if (entities.Count == 0)
|
||||||
return new List<Entity>();
|
return new List<Entity>();
|
||||||
|
|
||||||
InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters);
|
InsertFeatureEdges(entities, region, boundarySplitLines, feature, parameters, cutoutPolygons);
|
||||||
EnsurePerimeterWinding(entities);
|
// Winding is handled later in AssemblePieces, once connected components
|
||||||
|
// are known. At this stage the piece may still be multiple disjoint loops.
|
||||||
return entities;
|
return entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ProcessEntity(Entity entity, Box region,
|
private static void ProcessEntity(Entity entity, Box region, List<Entity> entities)
|
||||||
List<SplitLine> boundarySplitLines, List<Entity> 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<Entity> entities,
|
|
||||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
|
||||||
{
|
{
|
||||||
if (entity is Line line)
|
if (entity is Line line)
|
||||||
{
|
{
|
||||||
var (first, second) = line.SplitAt(point);
|
var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region);
|
||||||
if (startInRegion)
|
if (clipped == null) return;
|
||||||
{
|
if (clipped.Value.Start.DistanceTo(clipped.Value.End) < Math.Tolerance.Epsilon) return;
|
||||||
if (first != null) entities.Add(first);
|
entities.Add(new Line(clipped.Value.Start, clipped.Value.End));
|
||||||
splitPoints.Add((point, crossedLine, true));
|
return;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
splitPoints.Add((point, crossedLine, false));
|
|
||||||
if (second != null) entities.Add(second);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (entity is Arc arc)
|
|
||||||
|
if (entity is Arc arc)
|
||||||
{
|
{
|
||||||
var (first, second) = arc.SplitAt(point);
|
foreach (var sub in ClipArcToRegion(arc, region))
|
||||||
if (startInRegion)
|
entities.Add(sub);
|
||||||
{
|
return;
|
||||||
if (first != null) entities.Add(first);
|
|
||||||
splitPoints.Add((point, crossedLine, true));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
splitPoints.Add((point, crossedLine, false));
|
|
||||||
if (second != null) entities.Add(second);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Arc> 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> { arc };
|
||||||
|
|
||||||
|
foreach (var edge in edges)
|
||||||
|
{
|
||||||
|
var next = new List<Arc>();
|
||||||
|
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<Arc> { a };
|
||||||
|
foreach (var pt in pts)
|
||||||
|
{
|
||||||
|
var replaced = new List<Arc>();
|
||||||
|
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<Arc>();
|
||||||
|
foreach (var a in arcs)
|
||||||
|
{
|
||||||
|
if (region.Contains(a.MidPoint()))
|
||||||
|
result.Add(a);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns split lines whose position matches a boundary edge of the region.
|
/// Returns split lines whose position matches a boundary edge of the region.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -365,104 +388,157 @@ public static class DrawingSplitter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void InsertFeatureEdges(List<Entity> entities,
|
private static void InsertFeatureEdges(List<Entity> entities,
|
||||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints,
|
|
||||||
Box region, List<SplitLine> boundarySplitLines,
|
Box region, List<SplitLine> boundarySplitLines,
|
||||||
ISplitFeature feature, SplitParameters parameters)
|
ISplitFeature feature, SplitParameters parameters,
|
||||||
|
List<Polygon> cutoutPolygons)
|
||||||
{
|
{
|
||||||
// Group split points by their split line
|
foreach (var sl in boundarySplitLines)
|
||||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
|
||||||
foreach (var sp in splitPoints)
|
|
||||||
{
|
{
|
||||||
if (!groups.ContainsKey(sp.Line))
|
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||||
groups[sp.Line] = new List<(Vector, bool)>();
|
var extentStart = isVertical ? region.Bottom : region.Left;
|
||||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
var extentEnd = isVertical ? region.Top : region.Right;
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var kvp in groups)
|
if (extentEnd - extentStart < Math.Tolerance.Epsilon)
|
||||||
{
|
|
||||||
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)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// For each exit, find the matching entry to form the feature edge span
|
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
||||||
// Sort exits and entries by their position along the split line
|
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
||||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
||||||
exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
|
||||||
entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
|
||||||
|
|
||||||
// Pair them up: each exit with the next entry (or vice versa)
|
// Trim any line segments that cross a cutout — cut lines must never
|
||||||
var pairCount = System.Math.Min(exits.Count, entries.Count);
|
// travel through a hole.
|
||||||
for (var i = 0; i < pairCount; i++)
|
featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons);
|
||||||
|
|
||||||
|
entities.AddRange(featureEdge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subtracts any portions of line entities in <paramref name="featureEdge"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Entity> TrimFeatureEdgeAgainstCutouts(List<Entity> featureEdge, List<Polygon> cutoutPolygons)
|
||||||
|
{
|
||||||
|
if (cutoutPolygons.Count == 0 || featureEdge.Count == 0)
|
||||||
|
return featureEdge;
|
||||||
|
|
||||||
|
var result = new List<Entity>();
|
||||||
|
foreach (var entity in featureEdge)
|
||||||
|
{
|
||||||
|
if (entity is Line line)
|
||||||
|
result.AddRange(SubtractCutoutsFromLine(line, cutoutPolygons));
|
||||||
|
else
|
||||||
|
result.Add(entity);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the sub-segments of <paramref name="line"/> that lie outside every
|
||||||
|
/// cutout polygon. Handles the common axis-aligned feature-edge case exactly.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Line> SubtractCutoutsFromLine(Line line, List<Polygon> cutoutPolygons)
|
||||||
|
{
|
||||||
|
// Collect parameter values t in [0,1] where the line crosses any cutout edge.
|
||||||
|
var ts = new List<double> { 0.0, 1.0 };
|
||||||
|
foreach (var poly in cutoutPolygons)
|
||||||
|
{
|
||||||
|
var polyLines = poly.ToLines();
|
||||||
|
foreach (var edge in polyLines)
|
||||||
{
|
{
|
||||||
var exitPt = exits[i];
|
if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t))
|
||||||
var entryPt = entries[i];
|
{
|
||||||
|
if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon)
|
||||||
var extentStart = isVertical
|
ts.Add(t);
|
||||||
? 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
|
ts.Sort();
|
||||||
{
|
|
||||||
var featureStart = GetStartPoint(featureEdge[0]);
|
|
||||||
var featureEnd = GetEndPoint(featureEdge[^1]);
|
|
||||||
var isVertical = axis == CutOffAxis.Vertical;
|
|
||||||
|
|
||||||
var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X;
|
var segments = new List<Line>();
|
||||||
var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
|
for (var i = 0; i < ts.Count - 1; i++)
|
||||||
|
|
||||||
if (edgeGoesForward != featureGoesForward)
|
|
||||||
{
|
{
|
||||||
featureEdge = new List<Entity>(featureEdge);
|
var t0 = ts[i];
|
||||||
featureEdge.Reverse();
|
var t1 = ts[i + 1];
|
||||||
foreach (var e in featureEdge)
|
if (t1 - t0 < Math.Tolerance.Epsilon) continue;
|
||||||
e.Reverse();
|
|
||||||
|
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<Entity> entities)
|
/// <summary>
|
||||||
|
/// Segment-segment intersection. On hit, returns the parameter t along segment AB
|
||||||
|
/// (0 = a0, 1 = a1) via <paramref name="tOnA"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryIntersectSegments(Vector a0, Vector a1, Vector b0, Vector b1, out double tOnA)
|
||||||
{
|
{
|
||||||
var shape = new Shape();
|
tOnA = 0;
|
||||||
shape.Entities.AddRange(entities);
|
var rx = a1.X - a0.X;
|
||||||
var poly = shape.ToPolygon();
|
var ry = a1.Y - a0.Y;
|
||||||
if (poly != null && poly.RotationDirection() != RotationType.CW)
|
var sx = b1.X - b0.X;
|
||||||
shape.Reverse();
|
var sy = b1.Y - b0.Y;
|
||||||
|
|
||||||
entities.Clear();
|
var denom = rx * sy - ry * sx;
|
||||||
entities.AddRange(shape.Entities);
|
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)
|
private static bool IsCutoutInRegion(Shape cutout, Box region)
|
||||||
{
|
{
|
||||||
if (cutout.Entities.Count == 0) return false;
|
if (cutout.Entities.Count == 0) return false;
|
||||||
var pt = GetStartPoint(cutout.Entities[0]);
|
var bb = cutout.BoundingBox;
|
||||||
return region.Contains(pt);
|
// 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<SplitLine> splitLines)
|
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
|
||||||
@@ -479,57 +555,135 @@ public static class DrawingSplitter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clip a cutout shape to a region by walking entities, splitting at split line
|
/// Clip a cutout shape to a region by walking entities and splitting at split-line
|
||||||
/// intersections, keeping portions inside the region, and closing gaps with
|
/// crossings. Only returns the cutout-edge fragments that lie inside the region —
|
||||||
/// straight lines. No polygon clipping library needed.
|
/// 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 <see cref="InsertFeatureEdges"/>) and these
|
||||||
|
/// cutout fragments are stitched together later by <see cref="AssemblePieces"/>
|
||||||
|
/// using endpoint connectivity, which produces the correct closed loops — one
|
||||||
|
/// loop per physically-connected strip of material.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
|
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
|
||||||
{
|
{
|
||||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
|
||||||
|
|
||||||
foreach (var entity in cutout.Entities)
|
foreach (var entity in cutout.Entities)
|
||||||
|
ProcessEntity(entity, region, entities);
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="Drawing"/> — outer loop first, then its contained holes.
|
||||||
|
/// Each outer loop is normalized to CW winding and each hole to CCW.
|
||||||
|
/// </summary>
|
||||||
|
private static List<List<Entity>> AssemblePieces(List<Entity> entities)
|
||||||
|
{
|
||||||
|
var pieces = new List<List<Entity>>();
|
||||||
|
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<Polygon>(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)
|
// For each outer, attach the holes that fall inside it.
|
||||||
return new List<Entity>();
|
for (var i = 0; i < shapes.Count; i++)
|
||||||
|
|
||||||
// Close gaps with straight lines (connect exit→entry pairs)
|
|
||||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
|
||||||
foreach (var sp in splitPoints)
|
|
||||||
{
|
{
|
||||||
if (!groups.ContainsKey(sp.Line))
|
if (isHole[i]) continue;
|
||||||
groups[sp.Line] = new List<(Vector, bool)>();
|
|
||||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
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<Entity>();
|
||||||
|
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)
|
return pieces;
|
||||||
{
|
}
|
||||||
var sl = kvp.Key;
|
|
||||||
var points = kvp.Value;
|
|
||||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
|
||||||
|
|
||||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point)
|
/// <summary>
|
||||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
/// Returns the first vertex of a shape (start point of its first entity). Used as
|
||||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point)
|
/// a representative for containment testing: if bbox pre-check says the whole
|
||||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
/// shape is inside another, testing one vertex is sufficient to confirm.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
/// <summary>
|
||||||
for (var i = 0; i < pairCount; i++)
|
/// True iff box <paramref name="inner"/> is entirely inside box
|
||||||
entities.Add(new Line(exits[i], entries[i]));
|
/// <paramref name="outer"/> (tolerant comparison).
|
||||||
}
|
/// </summary>
|
||||||
|
private static bool BoxContainsBox(Box outer, Box inner)
|
||||||
// Ensure CCW winding for cutouts
|
{
|
||||||
var shape = new Shape();
|
var eps = Math.Tolerance.Epsilon;
|
||||||
shape.Entities.AddRange(entities);
|
return inner.Left >= outer.Left - eps
|
||||||
var poly = shape.ToPolygon();
|
&& inner.Right <= outer.Right + eps
|
||||||
if (poly != null && poly.RotationDirection() != RotationType.CCW)
|
&& inner.Bottom >= outer.Bottom - eps
|
||||||
shape.Reverse();
|
&& inner.Top <= outer.Top + eps;
|
||||||
|
|
||||||
return shape.Entities;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector GetStartPoint(Entity entity)
|
private static Vector GetStartPoint(Entity entity)
|
||||||
|
|||||||
@@ -34,6 +34,9 @@
|
|||||||
<Content Include="Bending\TestData\**\*">
|
<Content Include="Bending\TestData\**\*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="Splitting\TestData\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FlangeShape>(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
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,
|
||||||
|
Blind = true
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public void GetDrawing_NullOrEmptyPipeSize_OmitsCenterBore(string pipeSize)
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4,
|
||||||
|
PipeSize = pipeSize,
|
||||||
|
PipeClearance = 0.125
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
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<PipeFlangeShape>(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<PipeFlangeShape>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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_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()
|
||||||
|
{
|
||||||
|
Assert.Empty(PipeSizes.GetFittingSizes(0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<System.ArgumentException>(
|
||||||
|
() => PlateSizes.Recommend(System.Array.Empty<Box>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Entity>
|
||||||
|
{
|
||||||
|
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<Entity>
|
||||||
|
{
|
||||||
|
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<Entity>();
|
||||||
|
allEntities.AddRange(outerEntities);
|
||||||
|
allEntities.AddRange(slotEntities);
|
||||||
|
|
||||||
|
var drawing = new Drawing("SLOT", ConvertGeometry.ToProgram(allEntities));
|
||||||
|
var originalArea = drawing.Area;
|
||||||
|
|
||||||
|
var splitLines = new List<SplitLine>
|
||||||
|
{
|
||||||
|
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<Entity>();
|
||||||
|
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<SplitLine>
|
||||||
|
{
|
||||||
|
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)
|
private static Vector GetStartPoint(Entity entity)
|
||||||
{
|
{
|
||||||
return entity switch
|
return entity switch
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+366
-230
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ using OpenNest.Engine.Sequencing;
|
|||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using OpenNest.Properties;
|
using OpenNest.Properties;
|
||||||
|
using OpenNest.Shapes;
|
||||||
using System;
|
using System;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
@@ -453,7 +454,11 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
public void ResizePlateToFitParts()
|
public void ResizePlateToFitParts()
|
||||||
{
|
{
|
||||||
PlateView.Plate.AutoSize(Settings.Default.AutoSizePlateFactor);
|
var options = new PlateSizeOptions
|
||||||
|
{
|
||||||
|
SnapIncrement = Settings.Default.AutoSizePlateFactor,
|
||||||
|
};
|
||||||
|
PlateView.Plate.SnapToStandardSize(options);
|
||||||
PlateView.ZoomToPlate();
|
PlateView.ZoomToPlate();
|
||||||
PlateView.Refresh();
|
PlateView.Refresh();
|
||||||
UpdatePlateList();
|
UpdatePlateList();
|
||||||
|
|||||||
@@ -180,27 +180,66 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
y += 18;
|
y += 18;
|
||||||
|
|
||||||
var tb = new TextBox
|
Control editor;
|
||||||
|
if (prop.PropertyType == typeof(bool))
|
||||||
{
|
{
|
||||||
Location = new Point(parametersPanel.Padding.Left, y),
|
var cb = new CheckBox
|
||||||
Width = panelWidth,
|
{
|
||||||
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right
|
Location = new Point(parametersPanel.Padding.Left, y),
|
||||||
};
|
AutoSize = true,
|
||||||
|
Checked = sourceValues != null && (bool)prop.GetValue(sourceValues)
|
||||||
|
};
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
if (sourceValues != null)
|
// 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
|
||||||
{
|
{
|
||||||
if (prop.PropertyType == typeof(int))
|
var tb = new TextBox
|
||||||
tb.Text = ((int)prop.GetValue(sourceValues)).ToString();
|
{
|
||||||
else
|
Location = new Point(parametersPanel.Padding.Left, y),
|
||||||
tb.Text = ((double)prop.GetValue(sourceValues)).ToString("G");
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
tb.TextChanged += (s, ev) => UpdatePreview();
|
||||||
|
editor = tb;
|
||||||
}
|
}
|
||||||
|
|
||||||
tb.TextChanged += (s, ev) => UpdatePreview();
|
parameterBindings.Add(new ParameterBinding { Property = prop, Control = editor });
|
||||||
|
|
||||||
parameterBindings.Add(new ParameterBinding { Property = prop, Control = tb });
|
|
||||||
|
|
||||||
parametersPanel.Controls.Add(label);
|
parametersPanel.Controls.Add(label);
|
||||||
parametersPanel.Controls.Add(tb);
|
parametersPanel.Controls.Add(editor);
|
||||||
|
|
||||||
y += 30;
|
y += 30;
|
||||||
}
|
}
|
||||||
@@ -212,6 +251,8 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
if (suppressPreview || selectedEntry == null) return;
|
if (suppressPreview || selectedEntry == null) return;
|
||||||
|
|
||||||
|
UpdatePipeSizeFilter();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var shape = CreateShapeFromInputs();
|
var shape = CreateShapeFromInputs();
|
||||||
@@ -223,9 +264,17 @@ namespace OpenNest.Forms
|
|||||||
if (drawing?.Program != null)
|
if (drawing?.Program != null)
|
||||||
{
|
{
|
||||||
var bb = drawing.Program.BoundingBox();
|
var bb = drawing.Program.BoundingBox();
|
||||||
previewBox.SetInfo(
|
var info = string.Format("{0:F3} x {1:F3}", bb.Size.Length, bb.Size.Width);
|
||||||
nameTextBox.Text,
|
|
||||||
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
|
catch
|
||||||
@@ -234,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<string>().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()
|
private ShapeDefinition CreateShapeFromInputs()
|
||||||
{
|
{
|
||||||
var shape = (ShapeDefinition)Activator.CreateInstance(selectedEntry.ShapeType);
|
var shape = (ShapeDefinition)Activator.CreateInstance(selectedEntry.ShapeType);
|
||||||
@@ -241,6 +356,19 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
foreach (var binding in parameterBindings)
|
foreach (var binding in parameterBindings)
|
||||||
{
|
{
|
||||||
|
if (binding.Property.PropertyType == typeof(bool))
|
||||||
|
{
|
||||||
|
var cb = (CheckBox)binding.Control;
|
||||||
|
binding.Property.SetValue(shape, cb.Checked);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.Control is ComboBox combo)
|
||||||
|
{
|
||||||
|
binding.Property.SetValue(shape, combo.SelectedItem?.ToString());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var tb = (TextBox)binding.Control;
|
var tb = (TextBox)binding.Control;
|
||||||
|
|
||||||
if (binding.Property.PropertyType == typeof(int))
|
if (binding.Property.PropertyType == typeof(int))
|
||||||
|
|||||||
Reference in New Issue
Block a user