Compare commits
10 Commits
d215d02844
...
6880dee489
| Author | SHA1 | Date | |
|---|---|---|---|
| 6880dee489 | |||
| 0e45c13515 | |||
| 54def611fa | |||
| b1d094104a | |||
| 9d66b78a11 | |||
| eddbbca7ef | |||
| 4e7b5304a0 | |||
| 06485053fc | |||
| 92a57d33df | |||
| 6adc5b0967 |
@@ -1,6 +1,7 @@
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -548,6 +549,65 @@ namespace OpenNest
|
||||
Rounding.RoundUpToNearest(xExtent, roundingFactor));
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Gets the area of the top surface of the plate.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,31 +3,33 @@ using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Shapes
|
||||
{
|
||||
public class FlangeShape : ShapeDefinition
|
||||
public class PipeFlangeShape : ShapeDefinition
|
||||
{
|
||||
public double NominalPipeSize { get; set; }
|
||||
public double OD { get; set; }
|
||||
public double HoleDiameter { get; set; }
|
||||
public double HolePatternDiameter { get; set; }
|
||||
public int HoleCount { get; set; }
|
||||
public string PipeSize { get; set; }
|
||||
public double PipeClearance { get; set; }
|
||||
public bool Blind { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
NominalPipeSize = 2;
|
||||
OD = 7.5;
|
||||
HoleDiameter = 0.875;
|
||||
HolePatternDiameter = 5.5;
|
||||
HoleCount = 8;
|
||||
PipeSize = "2";
|
||||
PipeClearance = 0.0625;
|
||||
Blind = false;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
|
||||
// Outer circle
|
||||
entities.Add(new Circle(0, 0, OD / 2.0));
|
||||
|
||||
// Bolt holes evenly spaced on the bolt circle
|
||||
var boltCircleRadius = HolePatternDiameter / 2.0;
|
||||
var holeRadius = HoleDiameter / 2.0;
|
||||
var angleStep = 2.0 * System.Math.PI / HoleCount;
|
||||
@@ -40,6 +42,12 @@ namespace OpenNest.Shapes
|
||||
entities.Add(new Circle(cx, cy, holeRadius));
|
||||
}
|
||||
|
||||
if (!Blind && !string.IsNullOrEmpty(PipeSize) && PipeSizes.TryGetOD(PipeSize, out var pipeOD))
|
||||
{
|
||||
var boreDiameter = pipeOD + PipeClearance;
|
||||
entities.Add(new Circle(0, 0, boreDiameter / 2.0));
|
||||
}
|
||||
|
||||
return CreateDrawing(entities);
|
||||
}
|
||||
}
|
||||
@@ -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 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 pieceIndex = 1;
|
||||
|
||||
foreach (var region in regions)
|
||||
{
|
||||
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters);
|
||||
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters, cutoutPolygons);
|
||||
if (pieceEntities.Count == 0)
|
||||
continue;
|
||||
|
||||
@@ -47,10 +55,17 @@ public static class DrawingSplitter
|
||||
allEntities.AddRange(pieceEntities);
|
||||
allEntities.AddRange(cutoutEntities);
|
||||
|
||||
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
|
||||
// A single region may yield multiple physically-disjoint pieces when an
|
||||
// interior cutout spans across it. Group the region's entities into
|
||||
// connected closed loops, nest holes by containment, and emit one
|
||||
// Drawing per outer loop (with its contained holes).
|
||||
foreach (var pieceOfRegion in AssemblePieces(allEntities))
|
||||
{
|
||||
var piece = BuildPieceDrawing(drawing, pieceOfRegion, pieceIndex, region);
|
||||
results.Add(piece);
|
||||
pieceIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -218,98 +233,106 @@ public static class DrawingSplitter
|
||||
/// and stitching in feature edges. No polygon clipping library needed.
|
||||
/// </summary>
|
||||
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 entities = new List<Entity>();
|
||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
||||
|
||||
foreach (var entity in perimeter.Entities)
|
||||
{
|
||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
||||
}
|
||||
ProcessEntity(entity, region, entities);
|
||||
|
||||
if (entities.Count == 0)
|
||||
return new List<Entity>();
|
||||
|
||||
InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters);
|
||||
EnsurePerimeterWinding(entities);
|
||||
InsertFeatureEdges(entities, region, boundarySplitLines, feature, parameters, cutoutPolygons);
|
||||
// Winding is handled later in AssemblePieces, once connected components
|
||||
// are known. At this stage the piece may still be multiple disjoint loops.
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static void ProcessEntity(Entity entity, Box region,
|
||||
List<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)
|
||||
private static void ProcessEntity(Entity entity, Box region, List<Entity> entities)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
var (first, second) = line.SplitAt(point);
|
||||
if (startInRegion)
|
||||
var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region);
|
||||
if (clipped == null) return;
|
||||
if (clipped.Value.Start.DistanceTo(clipped.Value.End) < Math.Tolerance.Epsilon) return;
|
||||
entities.Add(new Line(clipped.Value.Start, clipped.Value.End));
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
if (first != null) entities.Add(first);
|
||||
splitPoints.Add((point, crossedLine, true));
|
||||
foreach (var sub in ClipArcToRegion(arc, region))
|
||||
entities.Add(sub);
|
||||
return;
|
||||
}
|
||||
else
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
splitPoints.Add((point, crossedLine, false));
|
||||
if (second != null) entities.Add(second);
|
||||
}
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
var edges = new[]
|
||||
{
|
||||
var (first, second) = arc.SplitAt(point);
|
||||
if (startInRegion)
|
||||
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)
|
||||
{
|
||||
if (first != null) entities.Add(first);
|
||||
splitPoints.Add((point, crossedLine, true));
|
||||
}
|
||||
else
|
||||
var next = new List<Arc>();
|
||||
foreach (var a in arcs)
|
||||
{
|
||||
splitPoints.Add((point, crossedLine, false));
|
||||
if (second != null) entities.Add(second);
|
||||
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>
|
||||
@@ -365,104 +388,157 @@ public static class DrawingSplitter
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private static void InsertFeatureEdges(List<Entity> entities,
|
||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints,
|
||||
Box region, List<SplitLine> boundarySplitLines,
|
||||
ISplitFeature feature, SplitParameters parameters)
|
||||
ISplitFeature feature, SplitParameters parameters,
|
||||
List<Polygon> cutoutPolygons)
|
||||
{
|
||||
// Group split points by their split line
|
||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
||||
foreach (var sp in splitPoints)
|
||||
foreach (var sl in boundarySplitLines)
|
||||
{
|
||||
if (!groups.ContainsKey(sp.Line))
|
||||
groups[sp.Line] = new List<(Vector, bool)>();
|
||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
||||
}
|
||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||
var extentStart = isVertical ? region.Bottom : region.Left;
|
||||
var extentEnd = isVertical ? region.Top : region.Right;
|
||||
|
||||
foreach (var kvp in groups)
|
||||
{
|
||||
var sl = kvp.Key;
|
||||
var points = kvp.Value;
|
||||
|
||||
// Pair each exit with the next entry
|
||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList();
|
||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList();
|
||||
|
||||
if (exits.Count == 0 || entries.Count == 0)
|
||||
if (extentEnd - extentStart < Math.Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
// For each exit, find the matching entry to form the feature edge span
|
||||
// Sort exits and entries by their position along the split line
|
||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||
exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
||||
entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
||||
|
||||
// Pair them up: each exit with the next entry (or vice versa)
|
||||
var pairCount = System.Math.Min(exits.Count, entries.Count);
|
||||
for (var i = 0; i < pairCount; i++)
|
||||
{
|
||||
var exitPt = exits[i];
|
||||
var entryPt = entries[i];
|
||||
|
||||
var extentStart = isVertical
|
||||
? System.Math.Min(exitPt.Y, entryPt.Y)
|
||||
: System.Math.Min(exitPt.X, entryPt.X);
|
||||
var extentEnd = isVertical
|
||||
? System.Math.Max(exitPt.Y, entryPt.Y)
|
||||
: System.Math.Max(exitPt.X, entryPt.X);
|
||||
|
||||
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
||||
|
||||
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
||||
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
||||
|
||||
if (featureEdge.Count > 0)
|
||||
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis);
|
||||
// Trim any line segments that cross a cutout — cut lines must never
|
||||
// travel through a hole.
|
||||
featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons);
|
||||
|
||||
entities.AddRange(featureEdge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
|
||||
/// <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)
|
||||
{
|
||||
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 featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
|
||||
|
||||
if (edgeGoesForward != featureGoesForward)
|
||||
{
|
||||
featureEdge = new List<Entity>(featureEdge);
|
||||
featureEdge.Reverse();
|
||||
foreach (var e in featureEdge)
|
||||
e.Reverse();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private static void EnsurePerimeterWinding(List<Entity> entities)
|
||||
/// <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)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.AddRange(entities);
|
||||
var poly = shape.ToPolygon();
|
||||
if (poly != null && poly.RotationDirection() != RotationType.CW)
|
||||
shape.Reverse();
|
||||
// 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)
|
||||
{
|
||||
if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t))
|
||||
{
|
||||
if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon)
|
||||
ts.Add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entities.Clear();
|
||||
entities.AddRange(shape.Entities);
|
||||
ts.Sort();
|
||||
|
||||
var segments = new List<Line>();
|
||||
for (var i = 0; i < ts.Count - 1; i++)
|
||||
{
|
||||
var t0 = ts[i];
|
||||
var t1 = ts[i + 1];
|
||||
if (t1 - t0 < Math.Tolerance.Epsilon) continue;
|
||||
|
||||
var tMid = (t0 + t1) * 0.5;
|
||||
var mid = new Vector(
|
||||
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * tMid,
|
||||
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * tMid);
|
||||
|
||||
var insideCutout = false;
|
||||
foreach (var poly in cutoutPolygons)
|
||||
{
|
||||
if (poly.ContainsPoint(mid))
|
||||
{
|
||||
insideCutout = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (insideCutout) continue;
|
||||
|
||||
var p0 = new Vector(
|
||||
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t0,
|
||||
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t0);
|
||||
var p1 = new Vector(
|
||||
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t1,
|
||||
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t1);
|
||||
|
||||
segments.Add(new Line(p0, p1));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
tOnA = 0;
|
||||
var rx = a1.X - a0.X;
|
||||
var ry = a1.Y - a0.Y;
|
||||
var sx = b1.X - b0.X;
|
||||
var sy = b1.Y - b0.Y;
|
||||
|
||||
var denom = rx * sy - ry * sx;
|
||||
if (System.Math.Abs(denom) < Math.Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
var dx = b0.X - a0.X;
|
||||
var dy = b0.Y - a0.Y;
|
||||
var t = (dx * sy - dy * sx) / denom;
|
||||
var u = (dx * ry - dy * rx) / denom;
|
||||
|
||||
if (t < -Math.Tolerance.Epsilon || t > 1 + Math.Tolerance.Epsilon) return false;
|
||||
if (u < -Math.Tolerance.Epsilon || u > 1 + Math.Tolerance.Epsilon) return false;
|
||||
|
||||
tOnA = t;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsCutoutInRegion(Shape cutout, Box region)
|
||||
{
|
||||
if (cutout.Entities.Count == 0) return false;
|
||||
var pt = GetStartPoint(cutout.Entities[0]);
|
||||
return region.Contains(pt);
|
||||
var bb = cutout.BoundingBox;
|
||||
// Fully contained iff the cutout's bounding box fits inside the region.
|
||||
return bb.Left >= region.Left - Math.Tolerance.Epsilon
|
||||
&& bb.Right <= region.Right + Math.Tolerance.Epsilon
|
||||
&& bb.Bottom >= region.Bottom - Math.Tolerance.Epsilon
|
||||
&& bb.Top <= region.Top + Math.Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
|
||||
@@ -479,57 +555,135 @@ public static class DrawingSplitter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clip a cutout shape to a region by walking entities, splitting at split line
|
||||
/// intersections, keeping portions inside the region, and closing gaps with
|
||||
/// straight lines. No polygon clipping library needed.
|
||||
/// Clip a cutout shape to a region by walking entities and splitting at split-line
|
||||
/// crossings. Only returns the cutout-edge fragments that lie inside the region —
|
||||
/// it deliberately does NOT emit synthetic closing lines at the region boundary.
|
||||
///
|
||||
/// Rationale: a closing line on the region boundary would overlap the split-line
|
||||
/// feature edge and reintroduce a cut through the cutout interior. The feature
|
||||
/// edge (trimmed against cutouts in <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>
|
||||
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
|
||||
{
|
||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
||||
var entities = new List<Entity>();
|
||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
||||
|
||||
foreach (var entity in cutout.Entities)
|
||||
{
|
||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
||||
ProcessEntity(entity, region, entities);
|
||||
return entities;
|
||||
}
|
||||
|
||||
if (entities.Count == 0)
|
||||
return new List<Entity>();
|
||||
|
||||
// Close gaps with straight lines (connect exit→entry pairs)
|
||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
||||
foreach (var sp in splitPoints)
|
||||
/// <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)
|
||||
{
|
||||
if (!groups.ContainsKey(sp.Line))
|
||||
groups[sp.Line] = new List<(Vector, bool)>();
|
||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
||||
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++)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kvp in groups)
|
||||
// For each outer, attach the holes that fall inside it.
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
var sl = kvp.Key;
|
||||
var points = kvp.Value;
|
||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||
if (isHole[i]) continue;
|
||||
|
||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point)
|
||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point)
|
||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
||||
var outer = shapes[i];
|
||||
var outerPoly = polygons[i];
|
||||
|
||||
var pairCount = System.Math.Min(exits.Count, entries.Count);
|
||||
for (var i = 0; i < pairCount; i++)
|
||||
entities.Add(new Line(exits[i], entries[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);
|
||||
}
|
||||
|
||||
// Ensure CCW winding for cutouts
|
||||
var shape = new Shape();
|
||||
shape.Entities.AddRange(entities);
|
||||
var poly = shape.ToPolygon();
|
||||
if (poly != null && poly.RotationDirection() != RotationType.CCW)
|
||||
shape.Reverse();
|
||||
pieces.Add(piece);
|
||||
}
|
||||
|
||||
return shape.Entities;
|
||||
return pieces;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first vertex of a shape (start point of its first entity). Used as
|
||||
/// a representative for containment testing: if bbox pre-check says the whole
|
||||
/// shape is inside another, testing one vertex is sufficient to confirm.
|
||||
/// </summary>
|
||||
private static Vector FirstVertexOf(Shape shape)
|
||||
{
|
||||
if (shape.Entities.Count == 0)
|
||||
return new Vector(0, 0);
|
||||
return GetStartPoint(shape.Entities[0]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True iff box <paramref name="inner"/> is entirely inside box
|
||||
/// <paramref name="outer"/> (tolerant comparison).
|
||||
/// </summary>
|
||||
private static bool BoxContainsBox(Box outer, Box inner)
|
||||
{
|
||||
var eps = Math.Tolerance.Epsilon;
|
||||
return inner.Left >= outer.Left - eps
|
||||
&& inner.Right <= outer.Right + eps
|
||||
&& inner.Bottom >= outer.Bottom - eps
|
||||
&& inner.Top <= outer.Top + eps;
|
||||
}
|
||||
|
||||
private static Vector GetStartPoint(Entity entity)
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
<Content Include="Bending\TestData\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Splitting\TestData\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</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,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)
|
||||
{
|
||||
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.Math;
|
||||
using OpenNest.Properties;
|
||||
using OpenNest.Shapes;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
@@ -453,7 +454,11 @@ namespace OpenNest.Forms
|
||||
|
||||
public void ResizePlateToFitParts()
|
||||
{
|
||||
PlateView.Plate.AutoSize(Settings.Default.AutoSizePlateFactor);
|
||||
var options = new PlateSizeOptions
|
||||
{
|
||||
SnapIncrement = Settings.Default.AutoSizePlateFactor,
|
||||
};
|
||||
PlateView.Plate.SnapToStandardSize(options);
|
||||
PlateView.ZoomToPlate();
|
||||
PlateView.Refresh();
|
||||
UpdatePlateList();
|
||||
|
||||
@@ -180,6 +180,43 @@ namespace OpenNest.Forms
|
||||
|
||||
y += 18;
|
||||
|
||||
Control editor;
|
||||
if (prop.PropertyType == typeof(bool))
|
||||
{
|
||||
var cb = new CheckBox
|
||||
{
|
||||
Location = new Point(parametersPanel.Padding.Left, y),
|
||||
AutoSize = true,
|
||||
Checked = sourceValues != null && (bool)prop.GetValue(sourceValues)
|
||||
};
|
||||
cb.CheckedChanged += (s, ev) => UpdatePreview();
|
||||
editor = cb;
|
||||
}
|
||||
else if (prop.PropertyType == typeof(string) && prop.Name == "PipeSize")
|
||||
{
|
||||
var combo = new ComboBox
|
||||
{
|
||||
Location = new Point(parametersPanel.Padding.Left, y),
|
||||
Width = panelWidth,
|
||||
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
|
||||
DropDownStyle = ComboBoxStyle.DropDownList
|
||||
};
|
||||
|
||||
// Initial population: every entry; the filter runs on first UpdatePreview.
|
||||
foreach (var entry in PipeSizes.All)
|
||||
combo.Items.Add(entry.Label);
|
||||
|
||||
var initial = sourceValues != null ? (string)prop.GetValue(sourceValues) : null;
|
||||
if (!string.IsNullOrEmpty(initial) && combo.Items.Contains(initial))
|
||||
combo.SelectedItem = initial;
|
||||
else if (combo.Items.Count > 0)
|
||||
combo.SelectedIndex = 0;
|
||||
|
||||
combo.SelectedIndexChanged += (s, ev) => UpdatePreview();
|
||||
editor = combo;
|
||||
}
|
||||
else
|
||||
{
|
||||
var tb = new TextBox
|
||||
{
|
||||
Location = new Point(parametersPanel.Padding.Left, y),
|
||||
@@ -196,11 +233,13 @@ namespace OpenNest.Forms
|
||||
}
|
||||
|
||||
tb.TextChanged += (s, ev) => UpdatePreview();
|
||||
editor = tb;
|
||||
}
|
||||
|
||||
parameterBindings.Add(new ParameterBinding { Property = prop, Control = tb });
|
||||
parameterBindings.Add(new ParameterBinding { Property = prop, Control = editor });
|
||||
|
||||
parametersPanel.Controls.Add(label);
|
||||
parametersPanel.Controls.Add(tb);
|
||||
parametersPanel.Controls.Add(editor);
|
||||
|
||||
y += 30;
|
||||
}
|
||||
@@ -212,6 +251,8 @@ namespace OpenNest.Forms
|
||||
{
|
||||
if (suppressPreview || selectedEntry == null) return;
|
||||
|
||||
UpdatePipeSizeFilter();
|
||||
|
||||
try
|
||||
{
|
||||
var shape = CreateShapeFromInputs();
|
||||
@@ -223,9 +264,17 @@ namespace OpenNest.Forms
|
||||
if (drawing?.Program != null)
|
||||
{
|
||||
var bb = drawing.Program.BoundingBox();
|
||||
previewBox.SetInfo(
|
||||
nameTextBox.Text,
|
||||
string.Format("{0:F3} x {1:F3}", bb.Size.Length, bb.Size.Width));
|
||||
var info = string.Format("{0:F3} x {1:F3}", bb.Size.Length, bb.Size.Width);
|
||||
|
||||
if (shape is PipeFlangeShape flange
|
||||
&& !flange.Blind
|
||||
&& !string.IsNullOrEmpty(flange.PipeSize)
|
||||
&& !PipeSizes.TryGetOD(flange.PipeSize, out _))
|
||||
{
|
||||
info += " — Invalid pipe size, no bore cut";
|
||||
}
|
||||
|
||||
previewBox.SetInfo(nameTextBox.Text, info);
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -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()
|
||||
{
|
||||
var shape = (ShapeDefinition)Activator.CreateInstance(selectedEntry.ShapeType);
|
||||
@@ -241,6 +356,19 @@ namespace OpenNest.Forms
|
||||
|
||||
foreach (var binding in parameterBindings)
|
||||
{
|
||||
if (binding.Property.PropertyType == typeof(bool))
|
||||
{
|
||||
var cb = (CheckBox)binding.Control;
|
||||
binding.Property.SetValue(shape, cb.Checked);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (binding.Control is ComboBox combo)
|
||||
{
|
||||
binding.Property.SetValue(shape, combo.SelectedItem?.ToString());
|
||||
continue;
|
||||
}
|
||||
|
||||
var tb = (TextBox)binding.Control;
|
||||
|
||||
if (binding.Property.PropertyType == typeof(int))
|
||||
|
||||
Reference in New Issue
Block a user