Compare commits
6 Commits
6880dee489
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c53d6fecd | |||
| e239967a7b | |||
| 9d57d3875a | |||
| 0e299d7f6f | |||
| c6f544c5d7 | |||
| 9563094c2b |
@@ -128,6 +128,12 @@ namespace OpenNest.CNC
|
||||
{
|
||||
var code = Codes[i];
|
||||
|
||||
if (code is SubProgramCall subpgm)
|
||||
{
|
||||
subpgm.Offset = new Geometry.Vector(
|
||||
subpgm.Offset.X + x, subpgm.Offset.Y + y);
|
||||
}
|
||||
|
||||
if (code is Motion == false)
|
||||
continue;
|
||||
|
||||
@@ -150,6 +156,12 @@ namespace OpenNest.CNC
|
||||
{
|
||||
var code = Codes[i];
|
||||
|
||||
if (code is SubProgramCall subpgm)
|
||||
{
|
||||
subpgm.Offset = new Geometry.Vector(
|
||||
subpgm.Offset.X + voffset.X, subpgm.Offset.Y + voffset.Y);
|
||||
}
|
||||
|
||||
if (code is Motion == false)
|
||||
continue;
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public interface IMaterialProvidingPostProcessor
|
||||
{
|
||||
IEnumerable<string> GetMaterialNames();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public interface IPostProcessorNestAware
|
||||
{
|
||||
void PrepareForNest(Nest nest);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -549,65 +548,6 @@ 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,33 +3,31 @@ using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Shapes
|
||||
{
|
||||
public class PipeFlangeShape : ShapeDefinition
|
||||
public class FlangeShape : 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;
|
||||
@@ -42,12 +40,6 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
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,20 +32,12 @@ 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, cutoutPolygons);
|
||||
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters);
|
||||
if (pieceEntities.Count == 0)
|
||||
continue;
|
||||
|
||||
@@ -55,16 +47,9 @@ public static class DrawingSplitter
|
||||
allEntities.AddRange(pieceEntities);
|
||||
allEntities.AddRange(cutoutEntities);
|
||||
|
||||
// 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++;
|
||||
}
|
||||
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
|
||||
results.Add(piece);
|
||||
pieceIndex++;
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -233,106 +218,98 @@ 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<Polygon> cutoutPolygons)
|
||||
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters)
|
||||
{
|
||||
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, entities);
|
||||
{
|
||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
||||
}
|
||||
|
||||
if (entities.Count == 0)
|
||||
return new List<Entity>();
|
||||
|
||||
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.
|
||||
InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters);
|
||||
EnsurePerimeterWinding(entities);
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static void ProcessEntity(Entity entity, Box region, List<Entity> entities)
|
||||
private static void ProcessEntity(Entity entity, Box region,
|
||||
List<SplitLine> boundarySplitLines, List<Entity> entities,
|
||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
||||
{
|
||||
if (entity is Line line)
|
||||
// Find the first boundary split line this entity crosses
|
||||
SplitLine crossedLine = null;
|
||||
Vector? intersectionPt = null;
|
||||
|
||||
foreach (var sl in boundarySplitLines)
|
||||
{
|
||||
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 (SplitLineIntersect.CrossesSplitLine(entity, sl))
|
||||
{
|
||||
var pt = SplitLineIntersect.FindIntersection(entity, sl);
|
||||
if (pt != null)
|
||||
{
|
||||
crossedLine = sl;
|
||||
intersectionPt = pt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entity is Arc arc)
|
||||
if (crossedLine != null)
|
||||
{
|
||||
foreach (var sub in ClipArcToRegion(arc, region))
|
||||
entities.Add(sub);
|
||||
return;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion,
|
||||
SplitLine crossedLine, List<Entity> entities,
|
||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
||||
{
|
||||
var edges = new[]
|
||||
if (entity is Line line)
|
||||
{
|
||||
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)
|
||||
var (first, second) = line.SplitAt(point);
|
||||
if (startInRegion)
|
||||
{
|
||||
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);
|
||||
if (first != null) entities.Add(first);
|
||||
splitPoints.Add((point, crossedLine, true));
|
||||
}
|
||||
else
|
||||
{
|
||||
splitPoints.Add((point, crossedLine, false));
|
||||
if (second != null) entities.Add(second);
|
||||
}
|
||||
arcs = next;
|
||||
}
|
||||
|
||||
var result = new List<Arc>();
|
||||
foreach (var a in arcs)
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
if (region.Contains(a.MidPoint()))
|
||||
result.Add(a);
|
||||
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);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -388,157 +365,104 @@ public static class DrawingSplitter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// Groups split points by split line, pairs exits with entries, and generates feature edges.
|
||||
/// </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,
|
||||
List<Polygon> cutoutPolygons)
|
||||
ISplitFeature feature, SplitParameters parameters)
|
||||
{
|
||||
foreach (var sl in boundarySplitLines)
|
||||
// Group split points by their split line
|
||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
||||
foreach (var sp in splitPoints)
|
||||
{
|
||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||
var extentStart = isVertical ? region.Bottom : region.Left;
|
||||
var extentEnd = isVertical ? region.Top : region.Right;
|
||||
if (!groups.ContainsKey(sp.Line))
|
||||
groups[sp.Line] = new List<(Vector, bool)>();
|
||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
||||
}
|
||||
|
||||
if (extentEnd - extentStart < Math.Tolerance.Epsilon)
|
||||
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)
|
||||
continue;
|
||||
|
||||
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
||||
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
||||
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
||||
// 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();
|
||||
|
||||
// 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)
|
||||
// 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++)
|
||||
{
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
|
||||
{
|
||||
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 featureStart = GetStartPoint(featureEdge[0]);
|
||||
var featureEnd = GetEndPoint(featureEdge[^1]);
|
||||
var isVertical = axis == CutOffAxis.Vertical;
|
||||
|
||||
var denom = rx * sy - ry * sx;
|
||||
if (System.Math.Abs(denom) < Math.Tolerance.Epsilon)
|
||||
return false;
|
||||
var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X;
|
||||
var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
|
||||
|
||||
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 (edgeGoesForward != featureGoesForward)
|
||||
{
|
||||
featureEdge = new List<Entity>(featureEdge);
|
||||
featureEdge.Reverse();
|
||||
foreach (var e in featureEdge)
|
||||
e.Reverse();
|
||||
}
|
||||
|
||||
if (t < -Math.Tolerance.Epsilon || t > 1 + Math.Tolerance.Epsilon) return false;
|
||||
if (u < -Math.Tolerance.Epsilon || u > 1 + Math.Tolerance.Epsilon) return false;
|
||||
return featureEdge;
|
||||
}
|
||||
|
||||
tOnA = t;
|
||||
return true;
|
||||
private static void EnsurePerimeterWinding(List<Entity> entities)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.AddRange(entities);
|
||||
var poly = shape.ToPolygon();
|
||||
if (poly != null && poly.RotationDirection() != RotationType.CW)
|
||||
shape.Reverse();
|
||||
|
||||
entities.Clear();
|
||||
entities.AddRange(shape.Entities);
|
||||
}
|
||||
|
||||
private static bool IsCutoutInRegion(Shape cutout, Box region)
|
||||
{
|
||||
if (cutout.Entities.Count == 0) return false;
|
||||
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;
|
||||
var pt = GetStartPoint(cutout.Entities[0]);
|
||||
return region.Contains(pt);
|
||||
}
|
||||
|
||||
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
|
||||
@@ -555,135 +479,57 @@ public static class DrawingSplitter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// </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++)
|
||||
{
|
||||
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;
|
||||
}
|
||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
||||
}
|
||||
|
||||
// For each outer, attach the holes that fall inside it.
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
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)
|
||||
{
|
||||
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);
|
||||
if (!groups.ContainsKey(sp.Line))
|
||||
groups[sp.Line] = new List<(Vector, bool)>();
|
||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
||||
}
|
||||
|
||||
return pieces;
|
||||
}
|
||||
foreach (var kvp in groups)
|
||||
{
|
||||
var sl = kvp.Key;
|
||||
var points = kvp.Value;
|
||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||
|
||||
/// <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 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>
|
||||
/// 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;
|
||||
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;
|
||||
}
|
||||
|
||||
private static Vector GetStartPoint(Entity entity)
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace OpenNest.Engine.Strategies
|
||||
public int PlateNumber { get; init; }
|
||||
public CancellationToken Token { get; init; }
|
||||
public IProgress<NestProgress> Progress { get; init; }
|
||||
public FillPolicy Policy { get; init; }
|
||||
public FillPolicy Policy { get; init; } = new FillPolicy(new DefaultFillComparer());
|
||||
public int MaxQuantity { get; init; }
|
||||
public PartType PartType { get; set; }
|
||||
|
||||
|
||||
@@ -16,11 +16,16 @@ public sealed class CincinnatiPartSubprogramWriter
|
||||
{
|
||||
private readonly CincinnatiPostConfig _config;
|
||||
private readonly CincinnatiFeatureWriter _featureWriter;
|
||||
private readonly CoordinateFormatter _fmt;
|
||||
private readonly Dictionary<int, int> _holeSubprograms;
|
||||
|
||||
public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config)
|
||||
public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config,
|
||||
Dictionary<int, int> holeSubprograms = null)
|
||||
{
|
||||
_config = config;
|
||||
_featureWriter = new CincinnatiFeatureWriter(config);
|
||||
_fmt = new CoordinateFormatter(config.PostedAccuracy);
|
||||
_holeSubprograms = holeSubprograms;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -44,6 +49,15 @@ public sealed class CincinnatiPartSubprogramWriter
|
||||
for (var i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
var (codes, isEtch) = ordered[i];
|
||||
var isLastFeature = i == ordered.Count - 1;
|
||||
|
||||
// SubProgramCall features are emitted as M98 hole calls
|
||||
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
|
||||
{
|
||||
WriteHoleSubprogramCall(w, holeCall, i, isLastFeature);
|
||||
continue;
|
||||
}
|
||||
|
||||
var featureNumber = i == 0
|
||||
? _config.FeatureLineNumberStart
|
||||
: 1000 + i + 1;
|
||||
@@ -55,7 +69,7 @@ public sealed class CincinnatiPartSubprogramWriter
|
||||
FeatureNumber = featureNumber,
|
||||
PartName = drawingName,
|
||||
IsFirstFeatureOfPart = false,
|
||||
IsLastFeatureOnSheet = i == ordered.Count - 1,
|
||||
IsLastFeatureOnSheet = isLastFeature,
|
||||
IsSafetyHeadraise = false,
|
||||
IsExteriorFeature = false,
|
||||
IsEtch = isEtch,
|
||||
@@ -70,6 +84,30 @@ public sealed class CincinnatiPartSubprogramWriter
|
||||
w.WriteLine($"M99 (END OF {drawingName})");
|
||||
}
|
||||
|
||||
private void WriteHoleSubprogramCall(TextWriter w, SubProgramCall call,
|
||||
int featureIndex, bool isLastFeature)
|
||||
{
|
||||
var postSubNum = _holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num)
|
||||
? num : call.Id;
|
||||
|
||||
var featureNumber = featureIndex == 0
|
||||
? _config.FeatureLineNumberStart
|
||||
: 1000 + featureIndex + 1;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (_config.UseLineNumbers)
|
||||
sb.Append($"N{featureNumber} ");
|
||||
sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}");
|
||||
w.WriteLine(sb.ToString());
|
||||
|
||||
w.WriteLine($"M98 P{postSubNum}");
|
||||
|
||||
w.WriteLine("G52 X0 Y0");
|
||||
|
||||
if (!isLastFeature)
|
||||
w.WriteLine("M47");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the program has no leading rapid, inserts a synthetic rapid at the
|
||||
/// last motion endpoint (the contour return point). This ensures the feature
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
@@ -277,6 +279,24 @@ namespace OpenNest.Posts.Cincinnati
|
||||
[DisplayName("Etch Libraries")]
|
||||
[Description("Gas-to-library mapping for etch operations.")]
|
||||
public List<EtchLibraryEntry> EtchLibraries { get; set; } = new();
|
||||
|
||||
[Category("B. Libraries")]
|
||||
[DisplayName("Selected Library")]
|
||||
[Description("Overrides Material/Thickness/Gas auto-resolution. Pick an existing entry from Material Libraries, or leave blank to auto-resolve.")]
|
||||
[TypeConverter(typeof(MaterialLibraryNameConverter))]
|
||||
public string SelectedLibrary { get; set; } = "";
|
||||
|
||||
public string FindBestLibrary(string materialName, double thickness)
|
||||
{
|
||||
if (MaterialLibraries == null || string.IsNullOrEmpty(materialName))
|
||||
return "";
|
||||
|
||||
return MaterialLibraries
|
||||
.Where(e => string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(e => System.Math.Abs(e.Thickness - thickness))
|
||||
.Select(e => e.Library)
|
||||
.FirstOrDefault() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
public class MaterialLibraryEntry
|
||||
|
||||
@@ -9,7 +9,7 @@ using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
public sealed class CincinnatiPostProcessor : IConfigurablePostProcessor
|
||||
public sealed class CincinnatiPostProcessor : IConfigurablePostProcessor, IPostProcessorNestAware, IMaterialProvidingPostProcessor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -25,6 +25,23 @@ namespace OpenNest.Posts.Cincinnati
|
||||
|
||||
object IConfigurablePostProcessor.Config => Config;
|
||||
|
||||
public IEnumerable<string> GetMaterialNames()
|
||||
{
|
||||
if (Config?.MaterialLibraries == null)
|
||||
return System.Array.Empty<string>();
|
||||
|
||||
return Config.MaterialLibraries
|
||||
.Select(e => e.Material)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
}
|
||||
|
||||
public void PrepareForNest(Nest nest)
|
||||
{
|
||||
var materialName = nest?.Material?.Name ?? "";
|
||||
var thickness = nest?.Thickness ?? 0.0;
|
||||
Config.SelectedLibrary = Config.FindBestLibrary(materialName, thickness);
|
||||
}
|
||||
|
||||
public CincinnatiPostProcessor()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
@@ -128,7 +145,8 @@ namespace OpenNest.Posts.Cincinnati
|
||||
// Part sub-programs (if enabled)
|
||||
if (subprogramEntries != null)
|
||||
{
|
||||
var partSubWriter = new CincinnatiPartSubprogramWriter(Config);
|
||||
var partSubWriter = new CincinnatiPartSubprogramWriter(Config,
|
||||
holeMapping.Count > 0 ? holeMapping : null);
|
||||
var sheetDiagonal = firstPlate != null
|
||||
? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width
|
||||
+ firstPlate.Size.Length * firstPlate.Size.Length)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
public sealed class MaterialLibraryNameConverter : StringConverter
|
||||
{
|
||||
public override bool GetStandardValuesSupported(ITypeDescriptorContext context) => true;
|
||||
|
||||
public override bool GetStandardValuesExclusive(ITypeDescriptorContext context) => false;
|
||||
|
||||
public override StandardValuesCollection GetStandardValues(ITypeDescriptorContext context)
|
||||
{
|
||||
var config = context?.Instance as CincinnatiPostConfig;
|
||||
var names = new List<string> { "" };
|
||||
|
||||
if (config?.MaterialLibraries != null)
|
||||
{
|
||||
names.AddRange(config.MaterialLibraries
|
||||
.Select(e => e.Library)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return new StandardValuesCollection(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,20 @@ public sealed class MaterialLibraryResolver
|
||||
|
||||
private readonly List<MaterialLibraryEntry> _materialLibraries;
|
||||
private readonly List<EtchLibraryEntry> _etchLibraries;
|
||||
private readonly string _selectedLibrary;
|
||||
|
||||
public MaterialLibraryResolver(CincinnatiPostConfig config)
|
||||
{
|
||||
_materialLibraries = config.MaterialLibraries ?? new List<MaterialLibraryEntry>();
|
||||
_etchLibraries = config.EtchLibraries ?? new List<EtchLibraryEntry>();
|
||||
_selectedLibrary = config.SelectedLibrary ?? "";
|
||||
}
|
||||
|
||||
public string ResolveCutLibrary(string materialName, double thickness, string gas)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_selectedLibrary))
|
||||
return EnsureLibExtension(_selectedLibrary);
|
||||
|
||||
var entry = _materialLibraries.FirstOrDefault(e =>
|
||||
string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase) &&
|
||||
System.Math.Abs(e.Thickness - thickness) <= ThicknessTolerance &&
|
||||
|
||||
@@ -6,11 +6,19 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="OpenNest.Posts.Cincinnati.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToPostsDir" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
|
||||
<ConfigJson>$(MSBuildProjectDirectory)\OpenNest.Posts.Cincinnati.json</ConfigJson>
|
||||
<DeployedConfigJson>$(PostsDir)OpenNest.Posts.Cincinnati.json</DeployedConfigJson>
|
||||
</PropertyGroup>
|
||||
<MakeDir Directories="$(PostsDir)" />
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
|
||||
<Copy SourceFiles="$(ConfigJson)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" Condition="!Exists('$(DeployedConfigJson)')" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"ConfigurationName": "CL940",
|
||||
"PostedUnits": "Inches",
|
||||
"PostedAccuracy": 4,
|
||||
"UseLineNumbers": true,
|
||||
"FeatureLineNumberStart": 1,
|
||||
"UseSheetSubprograms": true,
|
||||
"SheetSubprogramStart": 101,
|
||||
"UsePartSubprograms": false,
|
||||
"PartSubprogramStart": 200,
|
||||
"VariableDeclarationSubprogram": 100,
|
||||
"CoordModeBetweenParts": "G92",
|
||||
"ProcessParameterMode": "LibraryFile",
|
||||
"DefaultAssistGas": "O2",
|
||||
"DefaultEtchGas": "N2",
|
||||
"UseExactStopMode": false,
|
||||
"UseSpeedGas": false,
|
||||
"UseAntiDive": true,
|
||||
"UseSmartRapids": false,
|
||||
"KerfCompensation": "ControllerSide",
|
||||
"DefaultKerfSide": "Left",
|
||||
"InteriorM47": "Always",
|
||||
"ExteriorM47": "Always",
|
||||
"M47OverrideDistanceThreshold": null,
|
||||
"SafetyHeadraiseDistance": 2000,
|
||||
"PalletExchange": "EndOfSheet",
|
||||
"LeadInFeedratePercent": 0.5,
|
||||
"LeadInArcLine2FeedratePercent": 0.5,
|
||||
"LeadOutFeedratePercent": 0.5,
|
||||
"CircleFeedrateMultiplier": 0.8,
|
||||
"ArcFeedrate": "None",
|
||||
"ArcFeedrateRanges": [
|
||||
{ "MaxRadius": 0.125, "FeedratePercent": 0.25, "VariableNumber": 123 },
|
||||
{ "MaxRadius": 0.75, "FeedratePercent": 0.5, "VariableNumber": 124 },
|
||||
{ "MaxRadius": 4.5, "FeedratePercent": 0.8, "VariableNumber": 125 }
|
||||
],
|
||||
"UserVariableStart": 200,
|
||||
"SheetWidthVariable": 110,
|
||||
"SheetLengthVariable": 111,
|
||||
"MaterialLibraries": [
|
||||
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "AIR", "Library": "AL032AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "N2", "Library": "AL032N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "O2", "Library": "AL032O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "AIR", "Library": "AL050AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "N2", "Library": "AL050N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "O2", "Library": "AL050O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "AIR", "Library": "AL063AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "N2", "Library": "AL063N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "O2", "Library": "AL063O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "AIR", "Library": "AL080AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "N2", "Library": "AL080N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "O2", "Library": "AL080O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "AIR", "Library": "AL090AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "N2", "Library": "AL090N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "O2", "Library": "AL090O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "AIR", "Library": "AL100AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "N2", "Library": "AL100N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "O2", "Library": "AL100O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "AIR", "Library": "AL125AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "N2", "Library": "AL125N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "O2", "Library": "AL125O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "AIR", "Library": "AL190AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "N2", "Library": "AL190N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "O2", "Library": "AL190O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "AIR", "Library": "AL250AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "N2", "Library": "AL250N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "O2", "Library": "AL250O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "AIR", "Library": "AL375AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "N2", "Library": "AL375N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "O2", "Library": "AL375O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "AIR", "Library": "AL500AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "N2", "Library": "AL500N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "O2", "Library": "AL500O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.625, "Gas": "N2", "Library": "AL625N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "AIR", "Library": "AL750AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "N2", "Library": "AL750N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "O2", "Library": "AL750O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 1.000, "Gas": "AIR", "Library": "AL1000AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 1.000, "Gas": "N2", "Library": "AL1000N2" },
|
||||
|
||||
{ "Material": "Galvanized Steel", "Thickness": 0.135, "Gas": "N2", "Library": "GALV135N2" },
|
||||
{ "Material": "Galvanized Steel", "Thickness": 0.188, "Gas": "N2", "Library": "GALV188N2" },
|
||||
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.036, "Gas": "AIR", "Library": "MS036AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.036, "Gas": "N2", "Library": "MS036N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.048, "Gas": "AIR", "Library": "MS048AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.048, "Gas": "N2", "Library": "MS048N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.060, "Gas": "AIR", "Library": "MS060AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.060, "Gas": "N2", "Library": "MS060N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "AIR", "Library": "MS075AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "N2", "Library": "MS075N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "N2", "Library": "MS075N2FE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.090, "Gas": "N2", "Library": "MS090N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.105, "Gas": "AIR", "Library": "MS105AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.105, "Gas": "N2", "Library": "MS105N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "AIR", "Library": "MS120AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "N2", "Library": "MS120N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "N2", "Library": "MS120N2FE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "AIR", "Library": "MS135AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2FE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2Panel" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "AIR", "Library": "MS188AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "N2", "Library": "MS188N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "N2", "Library": "MS188N2FLOORPLATE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "O2", "Library": "MS188O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "AIR", "Library": "MS250AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "N2", "Library": "MS250N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "N2", "Library": "MS250N2FLOORPLATE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "O2", "Library": "MS250O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.313, "Gas": "O2", "Library": "MS313O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.375, "Gas": "O2", "Library": "MS375O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.500, "Gas": "N2", "Library": "MS500N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.500, "Gas": "O2", "Library": "MS500O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.625, "Gas": "O2", "Library": "MS625O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.750, "Gas": "O2", "Library": "MS750O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 1.000, "Gas": "O2", "Library": "MS1000O2" },
|
||||
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.036, "Gas": "AIR", "Library": "SS036AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.036, "Gas": "N2", "Library": "SS036N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.048, "Gas": "AIR", "Library": "SS048AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.048, "Gas": "N2", "Library": "SS048N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.060, "Gas": "AIR", "Library": "SS060AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.060, "Gas": "N2", "Library": "SS060N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "AIR", "Library": "SS075AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "N2", "Library": "SS075N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "N2", "Library": "SS075N2FE" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "AIR", "Library": "SS105AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "N2", "Library": "SS105N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "N2", "Library": "SS105N2FE" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "AIR", "Library": "SS120AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "N2", "Library": "SS120N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "N2", "Library": "SS120N2FE" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "AIR", "Library": "SS135AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "N2", "Library": "SS135N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "N2", "Library": "SS135N2FE" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.188, "Gas": "AIR", "Library": "SS188AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.188, "Gas": "N2", "Library": "SS188N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.250, "Gas": "AIR", "Library": "SS250AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.250, "Gas": "N2", "Library": "SS250N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.313, "Gas": "N2", "Library": "SS313N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.375, "Gas": "AIR", "Library": "SS375AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.375, "Gas": "N2", "Library": "SS375N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.500, "Gas": "AIR", "Library": "SS500AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.500, "Gas": "N2", "Library": "SS500N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.625, "Gas": "N2", "Library": "SS625N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.750, "Gas": "AIR", "Library": "SS750AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.750, "Gas": "N2", "Library": "SS750N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 1.000, "Gas": "AIR", "Library": "SS1000AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 1.000, "Gas": "N2", "Library": "SS1000N2" },
|
||||
|
||||
{ "Material": "Phenolic", "Thickness": 0.0, "Gas": "", "Library": "Phenolic" },
|
||||
{ "Material": "Gasket", "Thickness": 0.250, "Gas": "N2", "Library": "GASKET250N2" }
|
||||
],
|
||||
"EtchLibraries": [
|
||||
{ "Gas": "AIR", "Library": "EtchAIR" },
|
||||
{ "Gas": "N2", "Library": "EtchN2" },
|
||||
{ "Gas": "N2", "Library": "EtchN2_fast" },
|
||||
{ "Gas": "N2", "Library": "Etchn2_no_mark_pvc" },
|
||||
{ "Gas": "O2", "Library": "EtchO2" },
|
||||
{ "Gas": "O2", "Library": "ETCHO2FINE" }
|
||||
]
|
||||
}
|
||||
@@ -34,9 +34,6 @@
|
||||
<Content Include="Bending\TestData\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Splitting\TestData\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
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,161 +384,6 @@ 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
+230
-366
File diff suppressed because it is too large
Load Diff
Generated
+1
-1
@@ -81,8 +81,8 @@
|
||||
//
|
||||
// tabControl1
|
||||
//
|
||||
tabControl1.Controls.Add(tabPage1);
|
||||
tabControl1.Controls.Add(tabPage2);
|
||||
tabControl1.Controls.Add(tabPage1);
|
||||
tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
tabControl1.ItemSize = new System.Drawing.Size(100, 22);
|
||||
tabControl1.Location = new System.Drawing.Point(0, 0);
|
||||
|
||||
@@ -7,7 +7,6 @@ using OpenNest.Engine.Sequencing;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.Properties;
|
||||
using OpenNest.Shapes;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
@@ -454,11 +453,7 @@ namespace OpenNest.Forms
|
||||
|
||||
public void ResizePlateToFitParts()
|
||||
{
|
||||
var options = new PlateSizeOptions
|
||||
{
|
||||
SnapIncrement = Settings.Default.AutoSizePlateFactor,
|
||||
};
|
||||
PlateView.Plate.SnapToStandardSize(options);
|
||||
PlateView.Plate.AutoSize(Settings.Default.AutoSizePlateFactor);
|
||||
PlateView.ZoomToPlate();
|
||||
PlateView.Refresh();
|
||||
UpdatePlateList();
|
||||
|
||||
+4
-3
@@ -63,7 +63,7 @@
|
||||
this.textBox2 = new System.Windows.Forms.TextBox();
|
||||
this.label5 = new System.Windows.Forms.Label();
|
||||
this.labelMaterial = new System.Windows.Forms.Label();
|
||||
this.materialBox = new System.Windows.Forms.TextBox();
|
||||
this.materialBox = new System.Windows.Forms.ComboBox();
|
||||
this.tabPage2 = new System.Windows.Forms.TabPage();
|
||||
this.tabPage3 = new System.Windows.Forms.TabPage();
|
||||
this.notesBox = new System.Windows.Forms.TextBox();
|
||||
@@ -516,9 +516,10 @@
|
||||
// materialBox
|
||||
//
|
||||
this.materialBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.materialBox.FormattingEnabled = true;
|
||||
this.materialBox.Location = new System.Drawing.Point(135, 159);
|
||||
this.materialBox.Name = "materialBox";
|
||||
this.materialBox.Size = new System.Drawing.Size(224, 22);
|
||||
this.materialBox.Size = new System.Drawing.Size(224, 24);
|
||||
this.materialBox.TabIndex = 11;
|
||||
//
|
||||
// label3
|
||||
@@ -729,6 +730,6 @@
|
||||
private System.Windows.Forms.RadioButton radioButton2;
|
||||
private System.Windows.Forms.Label label5;
|
||||
private System.Windows.Forms.Label labelMaterial;
|
||||
private System.Windows.Forms.TextBox materialBox;
|
||||
private System.Windows.Forms.ComboBox materialBox;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ namespace OpenNest.Forms
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
foreach (var name in PostProcessorMaterials.Names)
|
||||
materialBox.Items.Add(name);
|
||||
|
||||
timer = new Timer
|
||||
{
|
||||
SynchronizingObject = this,
|
||||
|
||||
@@ -351,6 +351,9 @@ namespace OpenNest.Forms
|
||||
postProcessorMenuItem.Tag = postProcessor;
|
||||
postProcessorMenuItem.Click += PostProcessor_Click;
|
||||
mnuNestPost.DropDownItems.Add(postProcessorMenuItem);
|
||||
|
||||
if (postProcessor is IMaterialProvidingPostProcessor materialProvider)
|
||||
PostProcessorMaterials.AddFrom(materialProvider);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1157,6 +1160,9 @@ namespace OpenNest.Forms
|
||||
if (postProcessor == null)
|
||||
return;
|
||||
|
||||
if (postProcessor is IPostProcessorNestAware nestAware)
|
||||
nestAware.PrepareForNest(activeForm.Nest);
|
||||
|
||||
if (postProcessor is IConfigurablePostProcessor configurable)
|
||||
{
|
||||
using var configForm = new PostProcessorConfigForm(configurable);
|
||||
|
||||
@@ -180,66 +180,27 @@ namespace OpenNest.Forms
|
||||
|
||||
y += 18;
|
||||
|
||||
Control editor;
|
||||
if (prop.PropertyType == typeof(bool))
|
||||
var tb = new TextBox
|
||||
{
|
||||
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")
|
||||
Location = new Point(parametersPanel.Padding.Left, y),
|
||||
Width = panelWidth,
|
||||
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right
|
||||
};
|
||||
|
||||
if (sourceValues != null)
|
||||
{
|
||||
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),
|
||||
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;
|
||||
if (prop.PropertyType == typeof(int))
|
||||
tb.Text = ((int)prop.GetValue(sourceValues)).ToString();
|
||||
else
|
||||
tb.Text = ((double)prop.GetValue(sourceValues)).ToString("G");
|
||||
}
|
||||
|
||||
parameterBindings.Add(new ParameterBinding { Property = prop, Control = editor });
|
||||
tb.TextChanged += (s, ev) => UpdatePreview();
|
||||
|
||||
parameterBindings.Add(new ParameterBinding { Property = prop, Control = tb });
|
||||
|
||||
parametersPanel.Controls.Add(label);
|
||||
parametersPanel.Controls.Add(editor);
|
||||
parametersPanel.Controls.Add(tb);
|
||||
|
||||
y += 30;
|
||||
}
|
||||
@@ -251,8 +212,6 @@ namespace OpenNest.Forms
|
||||
{
|
||||
if (suppressPreview || selectedEntry == null) return;
|
||||
|
||||
UpdatePipeSizeFilter();
|
||||
|
||||
try
|
||||
{
|
||||
var shape = CreateShapeFromInputs();
|
||||
@@ -264,17 +223,9 @@ namespace OpenNest.Forms
|
||||
if (drawing?.Program != null)
|
||||
{
|
||||
var bb = drawing.Program.BoundingBox();
|
||||
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);
|
||||
previewBox.SetInfo(
|
||||
nameTextBox.Text,
|
||||
string.Format("{0:F3} x {1:F3}", bb.Size.Length, bb.Size.Width));
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -283,72 +234,6 @@ 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);
|
||||
@@ -356,19 +241,6 @@ 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))
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class PostProcessorMaterials
|
||||
{
|
||||
private static readonly List<string> materials = new();
|
||||
|
||||
public static IReadOnlyList<string> Names => materials;
|
||||
|
||||
public static void AddFrom(IMaterialProvidingPostProcessor provider)
|
||||
{
|
||||
if (provider == null)
|
||||
return;
|
||||
|
||||
foreach (var name in provider.GetMaterialNames())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name)
|
||||
&& !materials.Contains(name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
materials.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
materials.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user