Compare commits

12 Commits

Author SHA1 Message Date
aj 6880dee489 fix(splitter): preserve disconnected strips and trim cuts around cutouts
Splits that cross an interior cutout previously merged physically
disconnected strips into one drawing and drew cut lines through the hole.
The region boundary now spans full feature-edge extents (trimmed against
cutout polygons) and line entities are Liang-Barsky clipped, so multi-split
edges work. Arcs are properly clipped at region boundaries via iterative
split-at-intersection so circles that straddle a split contribute to both
sides. AssemblePieces groups a region's entities into connected closed
loops and nests holes by bbox-pre-check + vertex-in-polygon containment,
so one region can emit multiple drawings when a cutout fully spans it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:46:47 -04:00
aj 0e45c13515 feat(shapes): add PlateSizes catalog and wire Ctrl+P to snap-to-standard
PlateSizes holds standard mill sheet sizes (48x96 through 96x240) and
exposes Recommend() which snaps small layouts to an increment and
rounds larger layouts up to the nearest fitting sheet. Plate.SnapToStandardSize
applies the result while preserving long-axis orientation, and the
existing Ctrl+P "Resize to Fit" menu in EditNestForm now calls it
instead of the simple round-up AutoSize.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:16:29 -04:00
aj 54def611fa refactor(ui): switch CreateShapeFromInputs to control-type branching 2026-04-10 17:52:03 -04:00
aj b1d094104a feat(ui): add filtered pipe size dropdown to shape library
Renders PipeSize as a DropDownList ComboBox, filters entries to those fitting
the current hole geometry, disables the combo when Blind is checked, and
appends an invalid-pipe warning to the preview info when TryGetOD fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:50:01 -04:00
aj 9d66b78a11 feat(ui): add bool checkbox support to ShapeLibraryForm
BuildParameterControls now creates a CheckBox (wired to UpdatePreview) for bool properties instead of a TextBox; CreateShapeFromInputs reads the Checked value via a short-circuit before the TextBox cast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:47:36 -04:00
aj eddbbca7ef test(shapes): verify PipeFlangeShape JSON loading and shipped config integrity 2026-04-10 17:45:46 -04:00
aj 4e7b5304a0 chore(shapes): migrate flange config to PipeFlangeShape schema
Replace NominalPipeSize (double) with PipeSize (string label) and add
PipeClearance: 0.0625 to all 136 entries in PipeFlangeShape.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:42:16 -04:00
aj 06485053fc test(shapes): cover empty-string PipeSize in addition to null 2026-04-10 17:39:50 -04:00
aj 92a57d33df feat(shapes): add pipe bore, clearance, and blind flag to PipeFlangeShape
Replaces NominalPipeSize (double) with PipeSize (string), PipeClearance (double), and Blind (bool). GetDrawing cuts a center bore at pipeOD + PipeClearance unless Blind is true or PipeSize is unknown/null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:36:10 -04:00
aj 6adc5b0967 refactor(shapes): rename FlangeShape to PipeFlangeShape 2026-04-10 17:33:28 -04:00
aj d215d02844 style(shapes): remove redundant usings and document PipeSizes bound 2026-04-10 17:31:22 -04:00
aj 57863e16e9 feat(shapes): add ANSI pipe OD lookup table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:27:25 -04:00
16 changed files with 4686 additions and 545 deletions
+60
View File
@@ -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);
}
}
+78
View File
@@ -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;
}
}
}
}
}
+255
View File
@@ -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 &lt;= 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,
}
}
+342 -188
View File
@@ -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,9 +55,16 @@ public static class DrawingSplitter
allEntities.AddRange(pieceEntities);
allEntities.AddRange(cutoutEntities);
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
results.Add(piece);
pieceIndex++;
// A single region may yield multiple physically-disjoint pieces when an
// interior cutout spans across it. Group the region's entities into
// connected closed loops, nest holes by containment, and emit one
// Drawing per outer loop (with its contained holes).
foreach (var pieceOfRegion in AssemblePieces(allEntities))
{
var piece = BuildPieceDrawing(drawing, pieceOfRegion, pieceIndex, region);
results.Add(piece);
pieceIndex++;
}
}
return results;
@@ -218,100 +233,108 @@ public static class DrawingSplitter
/// and stitching in feature edges. No polygon clipping library needed.
/// </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)
{
if (first != null) entities.Add(first);
splitPoints.Add((point, crossedLine, true));
}
else
{
splitPoints.Add((point, crossedLine, false));
if (second != null) entities.Add(second);
}
var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region);
if (clipped == null) return;
if (clipped.Value.Start.DistanceTo(clipped.Value.End) < Math.Tolerance.Epsilon) return;
entities.Add(new Line(clipped.Value.Start, clipped.Value.End));
return;
}
else if (entity is Arc arc)
if (entity is Arc arc)
{
var (first, second) = arc.SplitAt(point);
if (startInRegion)
{
if (first != null) entities.Add(first);
splitPoints.Add((point, crossedLine, true));
}
else
{
splitPoints.Add((point, crossedLine, false));
if (second != null) entities.Add(second);
}
foreach (var sub in ClipArcToRegion(arc, region))
entities.Add(sub);
return;
}
}
/// <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>
/// Returns split lines whose position matches a boundary edge of the region.
/// </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();
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
var isNegativeSide = RegionSideOf(region, sl) < 0;
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
// Pair them up: each exit with the next entry (or vice versa)
var pairCount = System.Math.Min(exits.Count, entries.Count);
for (var i = 0; i < pairCount; i++)
// Trim any line segments that cross a cutout — cut lines must never
// travel through a hole.
featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons);
entities.AddRange(featureEdge);
}
}
/// <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];
var entryPt = entries[i];
var extentStart = isVertical
? System.Math.Min(exitPt.Y, entryPt.Y)
: System.Math.Min(exitPt.X, entryPt.X);
var extentEnd = isVertical
? System.Math.Max(exitPt.Y, entryPt.Y)
: System.Math.Max(exitPt.X, entryPt.X);
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
var isNegativeSide = RegionSideOf(region, sl) < 0;
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
if (featureEdge.Count > 0)
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis);
entities.AddRange(featureEdge);
if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t))
{
if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon)
ts.Add(t);
}
}
}
}
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
{
var featureStart = GetStartPoint(featureEdge[0]);
var featureEnd = GetEndPoint(featureEdge[^1]);
var isVertical = axis == CutOffAxis.Vertical;
ts.Sort();
var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X;
var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
if (edgeGoesForward != featureGoesForward)
var segments = new List<Line>();
for (var i = 0; i < ts.Count - 1; i++)
{
featureEdge = new List<Entity>(featureEdge);
featureEdge.Reverse();
foreach (var e in featureEdge)
e.Reverse();
var t0 = ts[i];
var t1 = ts[i + 1];
if (t1 - t0 < Math.Tolerance.Epsilon) continue;
var tMid = (t0 + t1) * 0.5;
var mid = new Vector(
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * tMid,
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * tMid);
var insideCutout = false;
foreach (var poly in cutoutPolygons)
{
if (poly.ContainsPoint(mid))
{
insideCutout = true;
break;
}
}
if (insideCutout) continue;
var p0 = new Vector(
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t0,
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t0);
var p1 = new Vector(
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t1,
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t1);
segments.Add(new Line(p0, p1));
}
return featureEdge;
return segments;
}
private static void EnsurePerimeterWinding(List<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();
shape.Entities.AddRange(entities);
var poly = shape.ToPolygon();
if (poly != null && poly.RotationDirection() != RotationType.CW)
shape.Reverse();
tOnA = 0;
var rx = a1.X - a0.X;
var ry = a1.Y - a0.Y;
var sx = b1.X - b0.X;
var sy = b1.Y - b0.Y;
entities.Clear();
entities.AddRange(shape.Entities);
var denom = rx * sy - ry * sx;
if (System.Math.Abs(denom) < Math.Tolerance.Epsilon)
return false;
var dx = b0.X - a0.X;
var dy = b0.Y - a0.Y;
var t = (dx * sy - dy * sx) / denom;
var u = (dx * ry - dy * rx) / denom;
if (t < -Math.Tolerance.Epsilon || t > 1 + Math.Tolerance.Epsilon) return false;
if (u < -Math.Tolerance.Epsilon || u > 1 + Math.Tolerance.Epsilon) return false;
tOnA = t;
return true;
}
private static bool IsCutoutInRegion(Shape cutout, Box region)
{
if (cutout.Entities.Count == 0) return false;
var pt = GetStartPoint(cutout.Entities[0]);
return region.Contains(pt);
var bb = cutout.BoundingBox;
// Fully contained iff the cutout's bounding box fits inside the region.
return bb.Left >= region.Left - Math.Tolerance.Epsilon
&& bb.Right <= region.Right + Math.Tolerance.Epsilon
&& bb.Bottom >= region.Bottom - Math.Tolerance.Epsilon
&& bb.Top <= region.Top + Math.Tolerance.Epsilon;
}
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<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, 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)
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)
// For each outer, attach the holes that fall inside it.
for (var i = 0; i < shapes.Count; i++)
{
if (!groups.ContainsKey(sp.Line))
groups[sp.Line] = new List<(Vector, bool)>();
groups[sp.Line].Add((sp.Point, sp.IsExit));
if (isHole[i]) continue;
var outer = shapes[i];
var outerPoly = polygons[i];
// Enforce perimeter winding = CW.
if (outerPoly != null && outerPoly.Vertices.Count >= 3
&& outerPoly.RotationDirection() != RotationType.CW)
outer.Reverse();
var piece = new List<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)
{
var sl = kvp.Key;
var points = kvp.Value;
var isVertical = sl.Axis == CutOffAxis.Vertical;
return pieces;
}
var exits = points.Where(p => p.IsExit).Select(p => p.Point)
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
var entries = points.Where(p => !p.IsExit).Select(p => p.Point)
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
/// <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]);
}
var pairCount = System.Math.Min(exits.Count, entries.Count);
for (var i = 0; i < pairCount; i++)
entities.Add(new Line(exits[i], entries[i]));
}
// Ensure CCW winding for cutouts
var shape = new Shape();
shape.Entities.AddRange(entities);
var poly = shape.ToPolygon();
if (poly != null && poly.RotationDirection() != RotationType.CCW)
shape.Reverse();
return shape.Entities;
/// <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)
+3
View File
@@ -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
}
}
-104
View File
@@ -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);
}
}
}
+64
View File
@@ -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));
}
}
+311
View File
@@ -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
+6 -1
View File
@@ -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();
+145 -17
View File
@@ -180,27 +180,66 @@ namespace OpenNest.Forms
y += 18;
var tb = new TextBox
Control editor;
if (prop.PropertyType == typeof(bool))
{
Location = new Point(parametersPanel.Padding.Left, y),
Width = panelWidth,
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right
};
var cb = new CheckBox
{
Location = new Point(parametersPanel.Padding.Left, y),
AutoSize = true,
Checked = sourceValues != null && (bool)prop.GetValue(sourceValues)
};
cb.CheckedChanged += (s, ev) => UpdatePreview();
editor = cb;
}
else 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))
tb.Text = ((int)prop.GetValue(sourceValues)).ToString();
else
tb.Text = ((double)prop.GetValue(sourceValues)).ToString("G");
var tb = new TextBox
{
Location = new Point(parametersPanel.Padding.Left, y),
Width = panelWidth,
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right
};
if (sourceValues != null)
{
if (prop.PropertyType == typeof(int))
tb.Text = ((int)prop.GetValue(sourceValues)).ToString();
else
tb.Text = ((double)prop.GetValue(sourceValues)).ToString("G");
}
tb.TextChanged += (s, ev) => UpdatePreview();
editor = tb;
}
tb.TextChanged += (s, ev) => UpdatePreview();
parameterBindings.Add(new ParameterBinding { Property = prop, Control = tb });
parameterBindings.Add(new ParameterBinding { Property = prop, Control = editor });
parametersPanel.Controls.Add(label);
parametersPanel.Controls.Add(tb);
parametersPanel.Controls.Add(editor);
y += 30;
}
@@ -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))