Compare commits

...

27 Commits

Author SHA1 Message Date
aj a34811bb6d fix: address review findings — input validation, exception handling, cleanup
Add argument validation to EllipseConverter.Convert for tolerance and
semi-axis parameters. Narrow bare catch in Extensions.cs spline method
to log via Debug.WriteLine. Remove unused lineCount variable from
SolidWorksBendDetectorTests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:34:13 -04:00
aj 9b460f77e5 test: add DXF import integration test for ellipse-to-arc conversion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:32:14 -04:00
aj 85bf779f21 feat: wire up EllipseConverter and SplineConverter in DXF import pipeline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:24:58 -04:00
aj 641c1cd461 feat: add SplineConverter with tangent-chained arc fitting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:16:12 -04:00
aj 4a5ed1b9c0 feat: add EllipseConverter arc fitting with normal-constrained G1 continuity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:01:55 -04:00
aj c40941ed35 feat: add EllipseConverter evaluation helpers with tests
Add EllipseConverter static class with foundational methods for converting
ellipse parameters to circular arcs: EvaluatePoint, EvaluateTangent,
EvaluateNormal, and IntersectNormals. All 8 unit tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:50:06 -04:00
aj d6184fdc8f docs: add implementation plan for direct arc conversion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:47:13 -04:00
aj d61ec1747a docs: add design spec for direct spline/ellipse to arc conversion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:39:10 -04:00
aj 7b815c9579 feat: auto-detect simplifiable geometry in CAD converter
When a file is loaded, a background task analyzes the entities for
simplification candidates and highlights the Simplify button with a
count when candidates are found. Button resets after simplification
is applied.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:15:05 -04:00
aj 5568789902 feat: add fill strategy enable/disable settings in options
OptionsForm now shows checkboxes for each fill strategy, persisted via
the new DisabledStrategies user setting. FillStrategyRegistry exposes
AllStrategies and DisabledNames for the UI. MainForm applies disabled
strategies on startup via OptionsForm.ApplyDisabledStrategies().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:14:10 -04:00
aj fd93cc9db2 test: add engine and strategy overlap tests, update stripe filler tests
New EngineOverlapTests verifies all engine types produce overlap-free
results. New StrategyOverlapTests checks each fill strategy individually.
StripeFillerTests updated to verify returned parts are overlap-free
rather than just asserting non-empty results. Remove obsolete FitCircle
tests from GeometrySimplifierTests (method was removed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:13:47 -04:00
aj 740fd79adc fix: add overlap validation guards to FillExtents and StripeFiller
FillExtents falls back to the unadjusted column when iterative pair
adjustment shifts parts enough to cause genuine overlap. StripeFiller
rejects grid results where bounding boxes overlap, which can occur when
angle convergence produces slightly off-axis rotations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:13:35 -04:00
aj e1b6752ede fix: improve overlap detection to ignore touch points and add bounding box pre-filtering
Part.Intersects now filters out intersection points that coincide with
vertices of both perimeters (shared corners/endpoints), which are touch
points rather than actual crossings. Plate.HasOverlappingParts adds a
bounding box pre-filter requiring overlap region to exceed Epsilon in
both dimensions before performing expensive shape intersection checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:13:21 -04:00
aj 18d9bbadfa refactor: extract SimplifierViewerForm designer file
Convert SimplifierViewerForm to partial class with standard WinForms
designer pattern. UI controls are now defined in the .Designer.cs file
with InitializeComponent(), enabling visual designer support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:11:12 -04:00
aj e27def388f fix: geometry simplifier arc connectivity and ellipse support
Three bugs prevented the simplifier from working on ellipse geometry:

1. Sweep angle check blocked initial fit — the 5-degree minimum sweep
   was inside TryFit(), killing candidates before the extension loop
   could accumulate enough segments. Moved to TryFitArcAt() after
   extension.

2. Layer reference equality split runs — entities from separate DXF
   ellipses had different Layer object instances for the same layer "0",
   splitting them into independent runs. Changed to compare Layer.Name.

3. Symmetrize replaced arcs with mirrored copies whose endpoints didn't
   match the target's original geometry, creating ~0.014 gaps. Now only
   applies mirrored arcs when endpoints are within tolerance of the
   target's boundary points.

Also: default tolerance 0.02 -> 0.004, Export DXF button in
CadConverterForm for debugging simplified geometry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:49:27 -04:00
aj 356b989424 feat: mirror axis simplifier, bend note propagation, ellipse fixes
Geometry Simplifier:
- Replace least-squares circle fitting with mirror axis algorithm
  that constrains center to perpendicular bisector of chord, guaranteeing
  zero-gap endpoint connectivity by construction
- Golden section search optimizes center position along the axis
- Increase default tolerance from 0.005 to 0.5 for practical CNC use
- Support existing arcs in simplification runs (sample arc points to
  find larger replacement arcs spanning lines + arcs together)
- Add tolerance zone visualization (offset original geometry ±tolerance)
- Show original geometry overlay with orange dashed lines in preview
- Add "Original" checkbox to CadConverter for comparing old vs new
- Store OriginalEntities on FileListItem to prevent tolerance creep
  when re-running simplifier with different settings

Bend Detection:
- Propagate bend notes to collinear bend lines split by cutouts
  using infinite-line perpendicular distance check
- Add bend note text rendering in EntityView at bend line midpoints

DXF Import:
- Fix trimmed ellipse closing chord: only close when sweep ≈ 2π,
  preventing phantom lines through slot cutouts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:27:46 -04:00
aj c6652f7707 fix: remove 0 from nest name encoding and padding
Use chars.Length instead of hardcoded 36 for modulus/division since
the character set excludes 0 and O. Pad with '2' (first valid char)
instead of '0' to avoid ambiguity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:11:09 -04:00
aj df008081d1 fix: persist simplified entities back to FileListItem
Without this, simplified geometry was lost on file switch and
not included in the final GetDrawings output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:50:07 -04:00
aj 0a294934ae feat: integrate geometry simplifier into CadConverterForm
Add "Simplify..." button to the detail bar and wire up SimplifierViewerForm
as a tool window with lazy creation, positioning, and entity replacement on apply.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:44:10 -04:00
aj f711a2e4d6 feat: add SimplifierViewerForm tool window
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:41:37 -04:00
aj a4df4027f1 feat: add simplifier highlight and preview rendering to EntityView
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:37:45 -04:00
aj 278bbe54ba feat: add GeometrySimplifier.Apply to replace lines with arcs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:35:08 -04:00
aj ca5eb53bc1 feat: add GeometrySimplifier.Analyze with incremental arc fitting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:30:10 -04:00
aj bbc02f6f3f feat: add ArcCandidate and Kasa circle fitting
Foundation for the geometry simplifier that will replace consecutive line
segments with fitted arcs. Adds ArcCandidate data class, GeometrySimplifier
with stub Analyze/Apply methods, and FitCircle using the Kasa algebraic
least-squares method. Also adds InternalsVisibleTo for OpenNest.Tests on
OpenNest.Core.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:22:05 -04:00
aj 12173204d1 fix: prevent etch line layers from defaulting to layer 0 after split
DxfImporter now filters ETCH entities (like BEND) since etch marks are
generated from bends during export, not cut geometry. GeometryOptimizer
no longer merges lines/arcs across different layers and preserves layer
and color on merged entities. EntityView draws etch marks directly from
the Bends list so they remain visible without relying on imported ETCH
entities.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:31:28 -04:00
aj cbabf5e9d1 refactor: extract shared feature utilities and sub-program registry from CincinnatiPostProcessor
Consolidate duplicated static methods (SplitFeatures, ComputeCutDistance,
IsFeatureEtch, feature ordering) from CincinnatiSheetWriter and
CincinnatiPartSubprogramWriter into a shared FeatureUtils class. Move
inline sub-program registry building from Post() into
CincinnatiPartSubprogramWriter.BuildRegistry().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:54:04 -04:00
aj 1aac03c9ef feat: add resizable split between sidebar and viewer in CadConverterForm
Wrap the left sidebar and right entity view in a SplitContainer so the
boundary can be dragged to resize. Fixed panel on the left with a 200px
minimum width.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:45:28 -04:00
44 changed files with 18905 additions and 346 deletions
+217
View File
@@ -0,0 +1,217 @@
using OpenNest.Math;
using System;
using System.Collections.Generic;
namespace OpenNest.Geometry
{
public static class EllipseConverter
{
private const int MaxSubdivisionDepth = 12;
private const int DeviationSamples = 20;
internal static Vector EvaluatePoint(double semiMajor, double semiMinor, double rotation, Vector center, double t)
{
var x = semiMajor * System.Math.Cos(t);
var y = semiMinor * System.Math.Sin(t);
var cos = System.Math.Cos(rotation);
var sin = System.Math.Sin(rotation);
return new Vector(
center.X + x * cos - y * sin,
center.Y + x * sin + y * cos);
}
internal static Vector EvaluateTangent(double semiMajor, double semiMinor, double rotation, double t)
{
var tx = -semiMajor * System.Math.Sin(t);
var ty = semiMinor * System.Math.Cos(t);
var cos = System.Math.Cos(rotation);
var sin = System.Math.Sin(rotation);
return new Vector(
tx * cos - ty * sin,
tx * sin + ty * cos);
}
internal static Vector EvaluateNormal(double semiMajor, double semiMinor, double rotation, double t)
{
// Inward normal: perpendicular to tangent, pointing toward center of curvature.
// In local coords: N(t) = (-b*cos(t), -a*sin(t))
var nx = -semiMinor * System.Math.Cos(t);
var ny = -semiMajor * System.Math.Sin(t);
var cos = System.Math.Cos(rotation);
var sin = System.Math.Sin(rotation);
return new Vector(
nx * cos - ny * sin,
nx * sin + ny * cos);
}
internal static Vector IntersectNormals(Vector p1, Vector n1, Vector p2, Vector n2)
{
// Solve: p1 + s*n1 = p2 + t*n2
var det = n1.X * (-n2.Y) - (-n2.X) * n1.Y;
if (System.Math.Abs(det) < 1e-10)
return Vector.Invalid;
var dx = p2.X - p1.X;
var dy = p2.Y - p1.Y;
var s = (dx * (-n2.Y) - dy * (-n2.X)) / det;
return new Vector(p1.X + s * n1.X, p1.Y + s * n1.Y);
}
public static List<Entity> Convert(Vector center, double semiMajor, double semiMinor,
double rotation, double startParam, double endParam, double tolerance = 0.001)
{
if (tolerance <= 0)
throw new ArgumentOutOfRangeException(nameof(tolerance), "Tolerance must be positive.");
if (semiMajor <= 0 || semiMinor <= 0)
throw new ArgumentOutOfRangeException("Semi-axis lengths must be positive.");
if (endParam <= startParam)
endParam += Angle.TwoPI;
// True circle — emit a single arc (or two for full circle)
if (System.Math.Abs(semiMajor - semiMinor) < Tolerance.Epsilon)
return ConvertCircle(center, semiMajor, rotation, startParam, endParam);
var splits = GetInitialSplits(startParam, endParam);
var entities = new List<Entity>();
for (var i = 0; i < splits.Count - 1; i++)
FitSegment(center, semiMajor, semiMinor, rotation,
splits[i], splits[i + 1], tolerance, entities, 0);
return entities;
}
private static List<Entity> ConvertCircle(Vector center, double radius,
double rotation, double startParam, double endParam)
{
var sweep = endParam - startParam;
var isFull = System.Math.Abs(sweep - Angle.TwoPI) < 0.01;
if (isFull)
{
var startAngle1 = Angle.NormalizeRad(startParam + rotation);
var midAngle = Angle.NormalizeRad(startParam + System.Math.PI + rotation);
var endAngle2 = startAngle1;
return new List<Entity>
{
new Arc(center, radius, startAngle1, midAngle, false),
new Arc(center, radius, midAngle, endAngle2, false)
};
}
var sa = Angle.NormalizeRad(startParam + rotation);
var ea = Angle.NormalizeRad(endParam + rotation);
return new List<Entity> { new Arc(center, radius, sa, ea, false) };
}
private static List<double> GetInitialSplits(double startParam, double endParam)
{
var splits = new List<double> { startParam };
var firstQuadrant = System.Math.Ceiling(startParam / (System.Math.PI / 2)) * (System.Math.PI / 2);
for (var q = firstQuadrant; q < endParam; q += System.Math.PI / 2)
{
if (q > startParam + 1e-10 && q < endParam - 1e-10)
splits.Add(q);
}
splits.Add(endParam);
return splits;
}
private static void FitSegment(Vector center, double semiMajor, double semiMinor,
double rotation, double t0, double t1, double tolerance, List<Entity> results, int depth)
{
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t0);
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t1);
if (p0.DistanceTo(p1) < 1e-10)
return;
var n0 = EvaluateNormal(semiMajor, semiMinor, rotation, t0);
var n1 = EvaluateNormal(semiMajor, semiMinor, rotation, t1);
var arcCenter = IntersectNormals(p0, n0, p1, n1);
if (!arcCenter.IsValid() || depth >= MaxSubdivisionDepth)
{
results.Add(new Line(p0, p1));
return;
}
var radius = p0.DistanceTo(arcCenter);
var maxDev = MeasureDeviation(center, semiMajor, semiMinor, rotation,
t0, t1, arcCenter, radius);
if (maxDev <= tolerance)
{
results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1));
}
else
{
var tMid = (t0 + t1) / 2.0;
FitSegment(center, semiMajor, semiMinor, rotation, t0, tMid, tolerance, results, depth + 1);
FitSegment(center, semiMajor, semiMinor, rotation, tMid, t1, tolerance, results, depth + 1);
}
}
private static double MeasureDeviation(Vector center, double semiMajor, double semiMinor,
double rotation, double t0, double t1, Vector arcCenter, double radius)
{
var maxDev = 0.0;
for (var i = 1; i <= DeviationSamples; i++)
{
var t = t0 + (t1 - t0) * i / DeviationSamples;
var p = EvaluatePoint(semiMajor, semiMinor, rotation, center, t);
var dist = p.DistanceTo(arcCenter);
var dev = System.Math.Abs(dist - radius);
if (dev > maxDev) maxDev = dev;
}
return maxDev;
}
private static Arc CreateArc(Vector arcCenter, double radius,
Vector ellipseCenter, double semiMajor, double semiMinor, double rotation,
double t0, double t1)
{
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t0);
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1);
var startAngle = System.Math.Atan2(p0.Y - arcCenter.Y, p0.X - arcCenter.X);
var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X);
var pMid = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, (t0 + t1) / 2);
var points = new List<Vector> { p0, pMid, p1 };
var isReversed = SumSignedAngles(arcCenter, points) < 0;
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;
return new Arc(arcCenter, radius, startAngle, endAngle, isReversed);
}
private static double SumSignedAngles(Vector center, List<Vector> points)
{
var total = 0.0;
for (var i = 0; i < points.Count - 1; i++)
{
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1;
while (da > System.Math.PI) da -= Angle.TwoPI;
while (da < -System.Math.PI) da += Angle.TwoPI;
total += da;
}
return total;
}
}
}
+9 -3
View File
@@ -76,6 +76,9 @@ namespace OpenNest.Geometry
if (line1 == line2)
return false;
if (line1.Layer?.Name != line2.Layer?.Name)
return false;
if (!line1.IsCollinearTo(line2))
return false;
@@ -113,9 +116,9 @@ namespace OpenNest.Geometry
var b = b1 < b2 ? b1 : b2;
if (!line1.IsVertical() && line1.Slope() < 0)
lineOut = new Line(new Vector(l, t), new Vector(r, b));
lineOut = new Line(new Vector(l, t), new Vector(r, b)) { Layer = line1.Layer, Color = line1.Color };
else
lineOut = new Line(new Vector(l, b), new Vector(r, t));
lineOut = new Line(new Vector(l, b), new Vector(r, t)) { Layer = line1.Layer, Color = line1.Color };
return true;
}
@@ -127,6 +130,9 @@ namespace OpenNest.Geometry
if (arc1 == arc2)
return false;
if (arc1.Layer?.Name != arc2.Layer?.Name)
return false;
if (arc1.Center != arc2.Center)
return false;
@@ -161,7 +167,7 @@ namespace OpenNest.Geometry
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle);
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle) { Layer = arc1.Layer, Color = arc1.Color };
return true;
}
@@ -0,0 +1,703 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry;
public class ArcCandidate
{
public int ShapeIndex { get; set; }
public int StartIndex { get; set; }
public int EndIndex { get; set; }
public int LineCount => EndIndex - StartIndex + 1;
public Arc FittedArc { get; set; }
public double MaxDeviation { get; set; }
public Box BoundingBox { get; set; }
public bool IsSelected { get; set; } = true;
/// <summary>First point of the original line segments this candidate covers.</summary>
public Vector FirstPoint { get; set; }
/// <summary>Last point of the original line segments this candidate covers.</summary>
public Vector LastPoint { get; set; }
}
/// <summary>
/// A mirror axis defined by a point on the axis and a unit direction vector.
/// </summary>
public class MirrorAxisResult
{
public static readonly MirrorAxisResult None = new(Vector.Invalid, Vector.Invalid, 0);
public Vector Point { get; }
public Vector Direction { get; }
public double Score { get; }
public bool IsValid => Point.IsValid();
public MirrorAxisResult(Vector point, Vector direction, double score)
{
Point = point;
Direction = direction;
Score = score;
}
/// <summary>Reflects a point across this axis.</summary>
public Vector Reflect(Vector p)
{
var dx = p.X - Point.X;
var dy = p.Y - Point.Y;
var dot = dx * Direction.X + dy * Direction.Y;
return new Vector(
p.X - 2 * (dx - dot * Direction.X),
p.Y - 2 * (dy - dot * Direction.Y));
}
}
public class GeometrySimplifier
{
public double Tolerance { get; set; } = 0.004;
public int MinLines { get; set; } = 3;
public List<ArcCandidate> Analyze(Shape shape)
{
var candidates = new List<ArcCandidate>();
var entities = shape.Entities;
var i = 0;
while (i < entities.Count)
{
if (entities[i] is not Line and not Arc)
{
i++;
continue;
}
var runStart = i;
var layerName = entities[i].Layer?.Name;
var lineCount = 0;
while (i < entities.Count && (entities[i] is Line || entities[i] is Arc) && entities[i].Layer?.Name == layerName)
{
if (entities[i] is Line) lineCount++;
i++;
}
var runEnd = i - 1;
if (lineCount >= MinLines)
FindCandidatesInRun(entities, runStart, runEnd, candidates);
}
return candidates;
}
public Shape Apply(Shape shape, List<ArcCandidate> candidates)
{
var selected = candidates
.Where(c => c.IsSelected)
.OrderBy(c => c.StartIndex)
.ToList();
var newEntities = new List<Entity>();
var i = 0;
foreach (var candidate in selected)
{
while (i < candidate.StartIndex)
{
newEntities.Add(shape.Entities[i]);
i++;
}
newEntities.Add(candidate.FittedArc);
i = candidate.EndIndex + 1;
}
while (i < shape.Entities.Count)
{
newEntities.Add(shape.Entities[i]);
i++;
}
var result = new Shape();
result.Entities.AddRange(newEntities);
return result;
}
/// <summary>
/// Detects the mirror axis of a shape by testing candidate axes through the
/// centroid. Uses PCA to find principal directions, then also tests horizontal
/// and vertical. Works for shapes rotated at any angle.
/// </summary>
public static MirrorAxisResult DetectMirrorAxis(Shape shape)
{
var midpoints = new List<Vector>();
foreach (var e in shape.Entities)
midpoints.Add(e.BoundingBox.Center);
if (midpoints.Count < 4) return MirrorAxisResult.None;
// Centroid
var cx = 0.0;
var cy = 0.0;
foreach (var p in midpoints) { cx += p.X; cy += p.Y; }
cx /= midpoints.Count;
cy /= midpoints.Count;
var centroid = new Vector(cx, cy);
// Covariance matrix for PCA
var cxx = 0.0;
var cxy = 0.0;
var cyy = 0.0;
foreach (var p in midpoints)
{
var dx = p.X - cx;
var dy = p.Y - cy;
cxx += dx * dx;
cxy += dx * dy;
cyy += dy * dy;
}
// Eigenvectors of 2x2 symmetric matrix via analytic formula
var trace = cxx + cyy;
var det = cxx * cyy - cxy * cxy;
var disc = System.Math.Sqrt(System.Math.Max(0, trace * trace / 4 - det));
var lambda1 = trace / 2 + disc;
var lambda2 = trace / 2 - disc;
var candidates = new List<Vector>();
// PCA eigenvectors (major and minor axes)
if (System.Math.Abs(cxy) > 1e-10)
{
candidates.Add(Normalize(new Vector(lambda1 - cyy, cxy)));
candidates.Add(Normalize(new Vector(lambda2 - cyy, cxy)));
}
else
{
candidates.Add(new Vector(1, 0));
candidates.Add(new Vector(0, 1));
}
// Also always test pure horizontal and vertical
candidates.Add(new Vector(1, 0));
candidates.Add(new Vector(0, 1));
// Score each candidate axis
var bestResult = MirrorAxisResult.None;
foreach (var dir in candidates)
{
var score = MirrorMatchScore(midpoints, centroid, dir);
if (score > bestResult.Score)
bestResult = new MirrorAxisResult(centroid, dir, score);
}
return bestResult.Score >= 0.8 ? bestResult : MirrorAxisResult.None;
}
private static Vector Normalize(Vector v)
{
var len = System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
return len < 1e-10 ? new Vector(1, 0) : new Vector(v.X / len, v.Y / len);
}
private static double MirrorMatchScore(List<Vector> points, Vector axisPoint, Vector axisDir)
{
var matchTol = 0.1;
var matched = 0;
for (var i = 0; i < points.Count; i++)
{
var p = points[i];
// Distance from point to axis
var dx = p.X - axisPoint.X;
var dy = p.Y - axisPoint.Y;
var dot = dx * axisDir.X + dy * axisDir.Y;
var perpX = dx - dot * axisDir.X;
var perpY = dy - dot * axisDir.Y;
var dist = System.Math.Sqrt(perpX * perpX + perpY * perpY);
// Points on the axis count as matched
if (dist < matchTol)
{
matched++;
continue;
}
// Reflect across axis and look for partner
var mx = p.X - 2 * perpX;
var my = p.Y - 2 * perpY;
for (var j = 0; j < points.Count; j++)
{
if (i == j) continue;
var d = System.Math.Sqrt((points[j].X - mx) * (points[j].X - mx) +
(points[j].Y - my) * (points[j].Y - my));
if (d < matchTol)
{
matched++;
break;
}
}
}
return (double)matched / points.Count;
}
/// <summary>
/// Pairs candidates across a mirror axis and forces each pair to use
/// the same arc (mirrored). The candidate with more lines or lower
/// deviation is kept as the source.
/// </summary>
public void Symmetrize(List<ArcCandidate> candidates, MirrorAxisResult axis)
{
if (!axis.IsValid || candidates.Count < 2) return;
var paired = new HashSet<int>();
for (var i = 0; i < candidates.Count; i++)
{
if (paired.Contains(i)) continue;
var ci = candidates[i];
var ciCenter = ci.BoundingBox.Center;
// Distance from candidate center to axis
var dx = ciCenter.X - axis.Point.X;
var dy = ciCenter.Y - axis.Point.Y;
var dot = dx * axis.Direction.X + dy * axis.Direction.Y;
var perpDist = System.Math.Sqrt((dx - dot * axis.Direction.X) * (dx - dot * axis.Direction.X) +
(dy - dot * axis.Direction.Y) * (dy - dot * axis.Direction.Y));
if (perpDist < 0.1) continue; // on the axis
var mirrorCenter = axis.Reflect(ciCenter);
var bestJ = -1;
var bestDist = double.MaxValue;
for (var j = i + 1; j < candidates.Count; j++)
{
if (paired.Contains(j)) continue;
var d = mirrorCenter.DistanceTo(candidates[j].BoundingBox.Center);
if (d < bestDist)
{
bestDist = d;
bestJ = j;
}
}
var matchTol = System.Math.Max(ci.BoundingBox.Width, ci.BoundingBox.Length) * 0.5;
if (bestJ < 0 || bestDist > matchTol) continue;
paired.Add(i);
paired.Add(bestJ);
var cj = candidates[bestJ];
var sourceIdx = i;
var targetIdx = bestJ;
if (cj.LineCount > ci.LineCount || (cj.LineCount == ci.LineCount && cj.MaxDeviation < ci.MaxDeviation))
{
sourceIdx = bestJ;
targetIdx = i;
}
var source = candidates[sourceIdx];
var target = candidates[targetIdx];
var mirrored = MirrorArc(source.FittedArc, axis);
// Only apply the mirrored arc if its endpoints are close enough to the
// target's actual boundary points. Otherwise the mirror introduces gaps.
var mirroredStart = mirrored.StartPoint();
var mirroredEnd = mirrored.EndPoint();
var startDist = mirroredStart.DistanceTo(target.FirstPoint);
var endDist = mirroredEnd.DistanceTo(target.LastPoint);
if (startDist <= Tolerance && endDist <= Tolerance)
{
target.FittedArc = mirrored;
target.MaxDeviation = source.MaxDeviation;
}
}
}
private static Arc MirrorArc(Arc arc, MirrorAxisResult axis)
{
var mirrorCenter = axis.Reflect(arc.Center);
// Reflect start and end points, then compute new angles
var sp = arc.StartPoint();
var ep = arc.EndPoint();
var mirrorSp = axis.Reflect(sp);
var mirrorEp = axis.Reflect(ep);
// Mirroring reverses winding — swap start/end to preserve arc direction
var mirrorStart = System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X);
var mirrorEnd = System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X);
// Normalize to [0, 2pi)
if (mirrorStart < 0) mirrorStart += Angle.TwoPI;
if (mirrorEnd < 0) mirrorEnd += Angle.TwoPI;
var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed);
result.Layer = arc.Layer;
result.Color = arc.Color;
return result;
}
private void FindCandidatesInRun(List<Entity> entities, int runStart, int runEnd, List<ArcCandidate> candidates)
{
var j = runStart;
var chainedTangent = Vector.Invalid;
while (j <= runEnd - MinLines + 1)
{
var result = TryFitArcAt(entities, j, runEnd, chainedTangent);
if (result == null)
{
j++;
chainedTangent = Vector.Invalid;
continue;
}
chainedTangent = ComputeEndTangent(result.Center, result.Points);
candidates.Add(new ArcCandidate
{
StartIndex = j,
EndIndex = result.EndIndex,
FittedArc = CreateArc(result.Center, result.Radius, result.Points, entities[j]),
MaxDeviation = result.Deviation,
BoundingBox = result.Points.GetBoundingBox(),
FirstPoint = result.Points[0],
LastPoint = result.Points[^1],
});
j = result.EndIndex + 1;
}
}
private record ArcFitResult(Vector Center, double Radius, double Deviation, List<Vector> Points, int EndIndex);
private ArcFitResult TryFitArcAt(List<Entity> entities, int start, int runEnd, Vector chainedTangent)
{
var k = start + MinLines - 1;
if (k > runEnd) return null;
var points = CollectPoints(entities, start, k);
if (points.Count < 3) return null;
var startTangent = chainedTangent.IsValid()
? chainedTangent
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
var (center, radius, dev) = TryFit(points, startTangent);
if (!center.IsValid()) return null;
// Extend the arc as far as possible
while (k + 1 <= runEnd)
{
var extPoints = CollectPoints(entities, start, k + 1);
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent) : (Vector.Invalid, 0, 0d);
if (!nc.IsValid()) break;
k++;
center = nc;
radius = nr;
dev = nd;
points = extPoints;
}
// Reject arcs that subtend a tiny angle — these are nearly-straight lines
// that happen to fit a huge circle. Applied after extension so that many small
// segments can accumulate enough sweep to qualify.
var sweep = System.Math.Abs(SumSignedAngles(center, points));
if (sweep < Angle.ToRadians(5))
return null;
return new ArcFitResult(center, radius, dev, points, k);
}
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent)
{
var (center, radius, dev) = FitWithStartTangent(points, startTangent);
if (!center.IsValid() || dev > Tolerance)
(center, radius, dev) = FitMirrorAxis(points);
if (!center.IsValid() || dev > Tolerance)
return (Vector.Invalid, 0, 0);
// Check that the arc doesn't bulge away from the original line segments
var isReversed = SumSignedAngles(center, points) < 0;
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
if (arcDev > Tolerance)
return (Vector.Invalid, 0, 0);
return (center, radius, System.Math.Max(dev, arcDev));
}
/// <summary>
/// Fits a circular arc constrained to be tangent to the given direction at the
/// first point. The center lies at the intersection of the normal at P1 (perpendicular
/// to the tangent) and the perpendicular bisector of the chord P1->Pn, guaranteeing
/// the arc passes through both endpoints and departs P1 in the given direction.
/// </summary>
private static (Vector center, double radius, double deviation) FitWithStartTangent(
List<Vector> points, Vector tangent)
{
if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue);
var p1 = points[0];
var pn = points[^1];
var mx = (p1.X + pn.X) / 2;
var my = (p1.Y + pn.Y) / 2;
var dx = pn.X - p1.X;
var dy = pn.Y - p1.Y;
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var bx = -dy / chordLen;
var by = dx / chordLen;
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
if (tLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var nx = -tangent.Y / tLen;
var ny = tangent.X / tLen;
var det = nx * by - ny * bx;
if (System.Math.Abs(det) < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var t = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
var cx = p1.X + t * nx;
var cy = p1.Y + t * ny;
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
if (radius < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
}
/// <summary>
/// Computes the tangent direction at the last point of a fitted arc,
/// used to chain tangent continuity to the next arc.
/// </summary>
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
{
var lastPt = points[^1];
var totalAngle = SumSignedAngles(center, points);
var rx = lastPt.X - center.X;
var ry = lastPt.Y - center.Y;
if (totalAngle >= 0)
return new Vector(-ry, rx);
else
return new Vector(ry, -rx);
}
/// <summary>
/// Fits a circular arc using the mirror axis approach. The center is constrained
/// to the perpendicular bisector of the chord (P1->Pn), guaranteeing the arc
/// passes exactly through both endpoints. Golden section search optimizes position.
/// </summary>
private (Vector center, double radius, double deviation) FitMirrorAxis(List<Vector> points)
{
if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue);
var p1 = points[0];
var pn = points[^1];
var mx = (p1.X + pn.X) / 2;
var my = (p1.Y + pn.Y) / 2;
var dx = pn.X - p1.X;
var dy = pn.Y - p1.Y;
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var halfChord = chordLen / 2;
var nx = -dy / chordLen;
var ny = dx / chordLen;
var maxSagitta = 0.0;
for (var i = 1; i < points.Count - 1; i++)
{
var proj = (points[i].X - mx) * nx + (points[i].Y - my) * ny;
if (System.Math.Abs(proj) > System.Math.Abs(maxSagitta))
maxSagitta = proj;
}
if (System.Math.Abs(maxSagitta) < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var dInit = (maxSagitta * maxSagitta - halfChord * halfChord) / (2 * maxSagitta);
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
var dOpt = GoldenSectionMin(dInit - range, dInit + range,
d => MaxRadialDeviation(points, mx + d * nx, my + d * ny,
System.Math.Sqrt(halfChord * halfChord + d * d)));
var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius));
}
private static double GoldenSectionMin(double low, double high, Func<double, double> eval)
{
var phi = (System.Math.Sqrt(5) - 1) / 2;
for (var iter = 0; iter < 30; iter++)
{
var d1 = high - phi * (high - low);
var d2 = low + phi * (high - low);
if (eval(d1) < eval(d2))
high = d2;
else
low = d1;
if (high - low < 1e-6)
break;
}
return (low + high) / 2;
}
private static List<Vector> CollectPoints(List<Entity> entities, int start, int end)
{
var points = new List<Vector>();
for (var i = start; i <= end; i++)
{
switch (entities[i])
{
case Line line:
if (i == start)
points.Add(line.StartPoint);
points.Add(line.EndPoint);
break;
case Arc arc:
if (i == start)
points.Add(arc.StartPoint());
var segments = System.Math.Max(2, arc.SegmentsForTolerance(0.1));
var arcPoints = arc.ToPoints(segments);
for (var j = 1; j < arcPoints.Count; j++)
points.Add(arcPoints[j]);
break;
}
}
return points;
}
private static Arc CreateArc(Vector center, double radius, List<Vector> points, Entity sourceEntity)
{
var firstPoint = points[0];
var lastPoint = points[^1];
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X);
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
var isReversed = SumSignedAngles(center, points) < 0;
// Normalize to [0, 2pi)
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;
var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
arc.Layer = sourceEntity.Layer;
arc.Color = sourceEntity.Color;
return arc;
}
/// <summary>
/// Sums signed angular change traversing consecutive points around a center.
/// Positive = CCW, negative = CW.
/// </summary>
private static double SumSignedAngles(Vector center, List<Vector> points)
{
var total = 0.0;
for (var i = 0; i < points.Count - 1; i++)
{
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1;
while (da > System.Math.PI) da -= Angle.TwoPI;
while (da < -System.Math.PI) da += Angle.TwoPI;
total += da;
}
return total;
}
/// <summary>
/// Max deviation of intermediate points (excluding endpoints) from a circle.
/// </summary>
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius)
{
var maxDev = 0.0;
for (var i = 1; i < points.Count - 1; i++)
{
var px = points[i].X - cx;
var py = points[i].Y - cy;
var dist = System.Math.Sqrt(px * px + py * py);
var dev = System.Math.Abs(dist - radius);
if (dev > maxDev) maxDev = dev;
}
return maxDev;
}
/// <summary>
/// Measures the maximum distance from sampled points along the fitted arc
/// back to the original line segments. This catches cases where points lie
/// on a large circle but the arc bulges far from the original straight geometry.
/// </summary>
private static double MaxArcToSegmentDeviation(List<Vector> points, Vector center, double radius, bool isReversed)
{
var startAngle = System.Math.Atan2(points[0].Y - center.Y, points[0].X - center.X);
var endAngle = System.Math.Atan2(points[^1].Y - center.Y, points[^1].X - center.X);
var sweep = endAngle - startAngle;
if (isReversed)
{
if (sweep > 0) sweep -= Angle.TwoPI;
}
else
{
if (sweep < 0) sweep += Angle.TwoPI;
}
var sampleCount = System.Math.Max(10, (int)(System.Math.Abs(sweep) * radius * 10));
sampleCount = System.Math.Min(sampleCount, 100);
var maxDev = 0.0;
for (var i = 1; i < sampleCount; i++)
{
var t = (double)i / sampleCount;
var angle = startAngle + sweep * t;
var px = center.X + radius * System.Math.Cos(angle);
var py = center.Y + radius * System.Math.Sin(angle);
var arcPt = new Vector(px, py);
var minDist = double.MaxValue;
for (var j = 0; j < points.Count - 1; j++)
{
var dist = DistanceToSegment(arcPt, points[j], points[j + 1]);
if (dist < minDist) minDist = dist;
}
if (minDist > maxDev) maxDev = minDist;
}
return maxDev;
}
private static double DistanceToSegment(Vector p, Vector a, Vector b)
{
var dx = b.X - a.X;
var dy = b.Y - a.Y;
var lenSq = dx * dx + dy * dy;
if (lenSq < 1e-20)
return System.Math.Sqrt((p.X - a.X) * (p.X - a.X) + (p.Y - a.Y) * (p.Y - a.Y));
var t = ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq;
t = System.Math.Max(0, System.Math.Min(1, t));
var projX = a.X + t * dx;
var projY = a.Y + t * dy;
return System.Math.Sqrt((p.X - projX) * (p.X - projX) + (p.Y - projY) * (p.Y - projY));
}
}
+247
View File
@@ -0,0 +1,247 @@
using OpenNest.Math;
using System;
using System.Collections.Generic;
namespace OpenNest.Geometry
{
public static class SplineConverter
{
private const int MinPointsForArc = 3;
public static List<Entity> Convert(List<Vector> points, bool isClosed, double tolerance = 0.001)
{
if (points == null || points.Count < 2)
return new List<Entity>();
var entities = new List<Entity>();
var i = 0;
var chainedTangent = Vector.Invalid;
while (i < points.Count - 1)
{
var result = TryFitArc(points, i, chainedTangent, tolerance);
if (result != null)
{
entities.Add(result.Arc);
chainedTangent = result.EndTangent;
i = result.EndIndex;
}
else
{
entities.Add(new Line(points[i], points[i + 1]));
chainedTangent = Vector.Invalid;
i++;
}
}
return entities;
}
private static ArcFitResult TryFitArc(List<Vector> points, int start,
Vector chainedTangent, double tolerance)
{
var minEnd = start + MinPointsForArc - 1;
if (minEnd >= points.Count)
return null;
var hasTangent = chainedTangent.IsValid();
var subPoints = points.GetRange(start, MinPointsForArc);
var (center, radius, dev) = hasTangent
? FitWithStartTangent(subPoints, chainedTangent)
: FitCircumscribed(subPoints);
if (!center.IsValid() || dev > tolerance)
return null;
var endIdx = minEnd;
while (endIdx + 1 < points.Count)
{
var extPoints = points.GetRange(start, endIdx + 1 - start + 1);
var (nc, nr, nd) = hasTangent
? FitWithStartTangent(extPoints, chainedTangent)
: FitCircumscribed(extPoints);
if (!nc.IsValid() || nd > tolerance)
break;
endIdx++;
center = nc;
radius = nr;
dev = nd;
}
var finalPoints = points.GetRange(start, endIdx - start + 1);
var sweep = System.Math.Abs(SumSignedAngles(center, finalPoints));
if (sweep < Angle.ToRadians(5))
return null;
var arc = CreateArc(center, radius, finalPoints);
var endTangent = ComputeEndTangent(center, finalPoints);
return new ArcFitResult(arc, endTangent, endIdx);
}
private static (Vector center, double radius, double deviation) FitCircumscribed(
List<Vector> points)
{
if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue);
var p0 = points[0];
var pMid = points[points.Count / 2];
var pEnd = points[^1];
// Find circumcenter by intersecting perpendicular bisectors of two chords
var (center, radius) = Circumcenter(p0, pMid, pEnd);
if (!center.IsValid())
return (Vector.Invalid, 0, double.MaxValue);
return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius));
}
private static (Vector center, double radius) Circumcenter(Vector a, Vector b, Vector c)
{
// Perpendicular bisector of chord a-b
var m1x = (a.X + b.X) / 2;
var m1y = (a.Y + b.Y) / 2;
var d1x = -(b.Y - a.Y);
var d1y = b.X - a.X;
// Perpendicular bisector of chord b-c
var m2x = (b.X + c.X) / 2;
var m2y = (b.Y + c.Y) / 2;
var d2x = -(c.Y - b.Y);
var d2y = c.X - b.X;
var det = d1x * d2y - d1y * d2x;
if (System.Math.Abs(det) < 1e-10)
return (Vector.Invalid, 0);
var t = ((m2x - m1x) * d2y - (m2y - m1y) * d2x) / det;
var cx = m1x + t * d1x;
var cy = m1y + t * d1y;
var radius = System.Math.Sqrt((cx - a.X) * (cx - a.X) + (cy - a.Y) * (cy - a.Y));
if (radius < 1e-10)
return (Vector.Invalid, 0);
return (new Vector(cx, cy), radius);
}
private static (Vector center, double radius, double deviation) FitWithStartTangent(
List<Vector> points, Vector tangent)
{
if (points.Count < 3)
return (Vector.Invalid, 0, double.MaxValue);
var p1 = points[0];
var pn = points[^1];
var mx = (p1.X + pn.X) / 2;
var my = (p1.Y + pn.Y) / 2;
var dx = pn.X - p1.X;
var dy = pn.Y - p1.Y;
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var bx = -dy / chordLen;
var by = dx / chordLen;
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
if (tLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var nx = -tangent.Y / tLen;
var ny = tangent.X / tLen;
var det = nx * by - ny * bx;
if (System.Math.Abs(det) < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var s = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
var cx = p1.X + s * nx;
var cy = p1.Y + s * ny;
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
if (radius < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
}
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius)
{
var maxDev = 0.0;
for (var i = 1; i < points.Count - 1; i++)
{
var px = points[i].X - cx;
var py = points[i].Y - cy;
var dist = System.Math.Sqrt(px * px + py * py);
var dev = System.Math.Abs(dist - radius);
if (dev > maxDev) maxDev = dev;
}
return maxDev;
}
private static double SumSignedAngles(Vector center, List<Vector> points)
{
var total = 0.0;
for (var i = 0; i < points.Count - 1; i++)
{
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
var da = a2 - a1;
while (da > System.Math.PI) da -= Angle.TwoPI;
while (da < -System.Math.PI) da += Angle.TwoPI;
total += da;
}
return total;
}
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
{
var lastPt = points[^1];
var totalAngle = SumSignedAngles(center, points);
var rx = lastPt.X - center.X;
var ry = lastPt.Y - center.Y;
return totalAngle >= 0
? new Vector(-ry, rx)
: new Vector(ry, -rx);
}
private static Arc CreateArc(Vector center, double radius, List<Vector> points)
{
var firstPoint = points[0];
var lastPoint = points[^1];
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X);
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
var isReversed = SumSignedAngles(center, points) < 0;
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;
return new Arc(center, radius, startAngle, endAngle, isReversed);
}
private sealed class ArcFitResult
{
public Arc Arc { get; }
public Vector EndTangent { get; }
public int EndIndex { get; }
public ArcFitResult(Arc arc, Vector endTangent, int endIndex)
{
Arc = arc;
EndTangent = endTangent;
EndIndex = endIndex;
}
}
}
}
+3
View File
@@ -4,6 +4,9 @@
<RootNamespace>OpenNest</RootNamespace>
<AssemblyName>OpenNest.Core</AssemblyName>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Clipper2" Version="2.0.0" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
+48 -1
View File
@@ -1,6 +1,7 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Linq;
@@ -173,7 +174,53 @@ namespace OpenNest
perimeter1.Offset(Location);
perimeter2.Offset(part.Location);
return perimeter1.Intersects(perimeter2, out pts);
if (!perimeter1.Intersects(perimeter2, out var rawPts))
return false;
// Exclude intersection points that coincide with vertices of BOTH
// perimeters — these are touch points (shared corners/endpoints),
// not actual crossings where one shape enters the other's interior.
var verts1 = CollectVertices(perimeter1);
var verts2 = CollectVertices(perimeter2);
foreach (var pt in rawPts)
{
if (IsNearAnyVertex(pt, verts1) && IsNearAnyVertex(pt, verts2))
continue;
pts.Add(pt);
}
return pts.Count > 0;
}
private static List<Vector> CollectVertices(Geometry.Shape shape)
{
var verts = new List<Vector>();
foreach (var entity in shape.Entities)
{
switch (entity)
{
case Geometry.Line line:
verts.Add(line.StartPoint);
verts.Add(line.EndPoint);
break;
case Geometry.Arc arc:
verts.Add(arc.StartPoint());
verts.Add(arc.EndPoint());
break;
}
}
return verts;
}
private static bool IsNearAnyVertex(Vector pt, List<Vector> vertices)
{
foreach (var v in vertices)
{
if (pt.X.IsEqualTo(v.X) && pt.Y.IsEqualTo(v.Y))
return true;
}
return false;
}
public double Left
+14
View File
@@ -601,10 +601,24 @@ namespace OpenNest
for (var i = 0; i < realParts.Count; i++)
{
var part1 = realParts[i];
var b1 = part1.BoundingBox;
for (var j = i + 1; j < realParts.Count; j++)
{
var part2 = realParts[j];
var b2 = part2.BoundingBox;
// Skip pairs whose bounding boxes don't meaningfully overlap.
// Floating-point rounding can produce sub-epsilon overlaps for
// parts that are merely edge-touching, so require the overlap
// region to exceed Epsilon in both dimensions.
var overlapX = System.Math.Min(b1.Right, b2.Right)
- System.Math.Max(b1.Left, b2.Left);
var overlapY = System.Math.Min(b1.Top, b2.Top)
- System.Math.Max(b1.Bottom, b2.Bottom);
if (overlapX <= Math.Tolerance.Epsilon || overlapY <= Math.Tolerance.Epsilon)
continue;
if (part1.Intersects(part2, out var pts2))
pts.AddRange(pts2);
+35 -1
View File
@@ -47,13 +47,21 @@ namespace OpenNest.Engine.Fill
var adjusted = AdjustColumn(pair.Value, column, token);
// The iterative pair adjustment can shift parts enough to cause
// genuine overlap. Fall back to the unadjusted column when this happens.
if (HasOverlappingParts(adjusted))
{
Debug.WriteLine("[FillExtents] Adjusted column has overlaps, using unadjusted");
adjusted = column;
}
NestEngineBase.ReportProgress(progress, new ProgressReport
{
Phase = NestPhase.Extents,
PlateNumber = plateNumber,
Parts = adjusted,
WorkArea = workArea,
Description = $"Extents: adjusted column {adjusted.Count} parts",
Description = $"Extents: column {adjusted.Count} parts",
});
var result = RepeatColumns(adjusted, token);
@@ -386,5 +394,31 @@ namespace OpenNest.Engine.Fill
part.BoundingBox.Left >= workArea.Left - Tolerance.Epsilon &&
part.BoundingBox.Bottom >= workArea.Bottom - Tolerance.Epsilon;
}
private static bool HasOverlappingParts(List<Part> parts)
{
for (var i = 0; i < parts.Count; i++)
{
var b1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var b2 = parts[j].BoundingBox;
var overlapX = System.Math.Min(b1.Right, b2.Right)
- System.Math.Max(b1.Left, b2.Left);
var overlapY = System.Math.Min(b1.Top, b2.Top)
- System.Math.Max(b1.Bottom, b2.Bottom);
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
continue;
if (parts[i].Intersects(parts[j], out _))
return true;
}
}
return false;
}
}
}
+39
View File
@@ -158,6 +158,15 @@ public class StripeFiller
if (gridParts.Count == 0)
return null;
// Reject results where bounding boxes overlap — the angle convergence
// can produce slightly off-axis rotations where FillLinear's copy
// distance calculation doesn't fully account for the rotated geometry.
if (HasOverlappingParts(gridParts))
{
Debug.WriteLine($"[StripeFiller] Rejected grid: overlapping bounding boxes detected");
return null;
}
var allParts = new List<Part>(gridParts);
var remnantParts = FillRemnant(gridParts, primaryAxis);
@@ -470,4 +479,34 @@ public class StripeFiller
{
return axis == NestDirection.Horizontal ? box.Width : box.Length;
}
/// <summary>
/// Checks if any pair of parts geometrically overlap. Uses bounding box
/// pre-filtering for performance, then falls back to shape intersection.
/// </summary>
private static bool HasOverlappingParts(List<Part> parts)
{
for (var i = 0; i < parts.Count; i++)
{
var b1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var b2 = parts[j].BoundingBox;
var overlapX = System.Math.Min(b1.Right, b2.Right)
- System.Math.Max(b1.Left, b2.Left);
var overlapY = System.Math.Min(b1.Top, b2.Top)
- System.Math.Max(b1.Bottom, b2.Bottom);
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
continue;
if (parts[i].Intersects(parts[j], out _))
return true;
}
}
return false;
}
}
@@ -22,6 +22,17 @@ namespace OpenNest.Engine.Strategies
public static IReadOnlyList<IFillStrategy> Strategies =>
sorted ??= FilterStrategies();
/// <summary>
/// Returns all registered strategies regardless of enabled/disabled state.
/// </summary>
public static IReadOnlyList<IFillStrategy> AllStrategies =>
strategies.OrderBy(s => s.Order).ToList();
/// <summary>
/// Returns the names of all permanently disabled strategies.
/// </summary>
public static IReadOnlyCollection<string> DisabledNames => disabled;
private static List<IFillStrategy> FilterStrategies()
{
var source = enabledFilter != null
@@ -63,9 +63,72 @@ namespace OpenNest.IO.Bending
bends.Add(bend);
}
PropagateCollinearBendNotes(bends);
return bends;
}
/// <summary>
/// For bends without a note (e.g. split by a cutout), copy angle/radius/direction
/// from a collinear bend that does have a note.
/// </summary>
private static void PropagateCollinearBendNotes(List<Bend> bends)
{
const double angleTolerance = 0.01; // radians
const double distanceTolerance = 0.01;
foreach (var bend in bends)
{
if (!string.IsNullOrEmpty(bend.NoteText))
continue;
foreach (var other in bends)
{
if (string.IsNullOrEmpty(other.NoteText))
continue;
if (!AreCollinear(bend, other, angleTolerance, distanceTolerance))
continue;
bend.Direction = other.Direction;
bend.Angle = other.Angle;
bend.Radius = other.Radius;
bend.NoteText = other.NoteText;
break;
}
}
}
private static bool AreCollinear(Bend a, Bend b, double angleTolerance, double distanceTolerance)
{
var angleA = a.StartPoint.AngleTo(a.EndPoint);
var angleB = b.StartPoint.AngleTo(b.EndPoint);
// Normalize angle difference to [0, PI) since opposite directions are still collinear
var diff = System.Math.Abs(angleA - angleB) % System.Math.PI;
if (diff > angleTolerance && System.Math.PI - diff > angleTolerance)
return false;
// Perpendicular distance from midpoint of A to the infinite line through B
var midA = new Vector(
(a.StartPoint.X + a.EndPoint.X) / 2.0,
(a.StartPoint.Y + a.EndPoint.Y) / 2.0);
var dx = b.EndPoint.X - b.StartPoint.X;
var dy = b.EndPoint.Y - b.StartPoint.Y;
var len = System.Math.Sqrt(dx * dx + dy * dy);
if (len < 1e-9)
return false;
// 2D cross product gives signed perpendicular distance * length
var vx = midA.X - b.StartPoint.X;
var vy = midA.Y - b.StartPoint.Y;
var perp = System.Math.Abs(vx * dy - vy * dx) / len;
return perp <= distanceTolerance;
}
private List<ACadSharp.Entities.Line> FindBendLines(CadDocument document)
{
return document.Entities
+17 -7
View File
@@ -24,9 +24,10 @@ namespace OpenNest.IO
foreach (var entity in doc.Entities)
{
// Skip bend line entities — they are converted to Bend objects
// separately via bend detection, not cut geometry.
if (IsBendLayer(entity.Layer?.Name))
// Skip bend/etch entities — bends are converted to Bend objects
// separately via bend detection, and etch marks are generated from
// bends during DXF export. Neither should be treated as cut geometry.
if (IsNonCutLayer(entity.Layer?.Name))
continue;
switch (entity)
@@ -44,7 +45,11 @@ namespace OpenNest.IO
break;
case ACadSharp.Entities.Spline spline:
lines.AddRange(spline.ToOpenNest());
foreach (var e in spline.ToOpenNest(SplinePrecision))
{
if (e is Line l) lines.Add(l);
else if (e is Arc a) arcs.Add(a);
}
break;
case ACadSharp.Entities.LwPolyline lwPolyline:
@@ -56,7 +61,11 @@ namespace OpenNest.IO
break;
case ACadSharp.Entities.Ellipse ellipse:
lines.AddRange(ellipse.ToOpenNest(SplinePrecision));
foreach (var e in ellipse.ToOpenNest())
{
if (e is Line l) lines.Add(l);
else if (e is Arc a) arcs.Add(a);
}
break;
}
}
@@ -137,9 +146,10 @@ namespace OpenNest.IO
return success;
}
private static bool IsBendLayer(string layerName)
private static bool IsNonCutLayer(string layerName)
{
return string.Equals(layerName, "BEND", System.StringComparison.OrdinalIgnoreCase);
return string.Equals(layerName, "BEND", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(layerName, "ETCH", System.StringComparison.OrdinalIgnoreCase);
}
}
}
+43 -75
View File
@@ -1,6 +1,7 @@
using ACadSharp.Entities;
using CSMath;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
@@ -56,42 +57,46 @@ namespace OpenNest.IO
return result;
}
public static List<Geometry.Line> ToOpenNest(this Spline spline)
public static List<Geometry.Entity> ToOpenNest(this Spline spline, int precision)
{
var lines = new List<Geometry.Line>();
var pts = spline.ControlPoints;
if (pts.Count == 0)
return lines;
var layer = spline.Layer.ToOpenNest();
var color = spline.ResolveColor();
var lineTypeName = spline.ResolveLineTypeName();
var lastPoint = pts[0].ToOpenNest();
for (var i = 1; i < pts.Count; i++)
// Evaluate actual points on the spline curve (not control points)
List<XYZ> curvePoints;
try
{
var nextPoint = pts[i].ToOpenNest();
lines.Add(new Geometry.Line(lastPoint, nextPoint)
{
Layer = layer,
Color = color,
LineTypeName = lineTypeName
});
lastPoint = nextPoint;
curvePoints = spline.PolygonalVertexes(precision > 0 ? precision : 200);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Spline curve evaluation failed: {ex.Message}");
curvePoints = null;
}
if (spline.IsClosed)
lines.Add(new Geometry.Line(lastPoint, pts[0].ToOpenNest())
{
Layer = layer,
Color = color,
LineTypeName = lineTypeName
});
if (curvePoints == null || curvePoints.Count < 2)
{
// Fallback: use control points if evaluation fails
curvePoints = new List<XYZ>(spline.ControlPoints);
if (curvePoints.Count < 2)
return new List<Geometry.Entity>();
}
return lines;
var points = new List<Vector>(curvePoints.Count);
foreach (var pt in curvePoints)
points.Add(pt.ToOpenNest());
var entities = SplineConverter.Convert(points, spline.IsClosed, tolerance: 0.001);
foreach (var entity in entities)
{
entity.Layer = layer;
entity.Color = color;
entity.LineTypeName = lineTypeName;
}
return entities;
}
public static List<Geometry.Line> ToOpenNest(this Polyline polyline)
@@ -172,69 +177,32 @@ namespace OpenNest.IO
return lines;
}
public static List<Geometry.Line> ToOpenNest(this ACadSharp.Entities.Ellipse ellipse, int precision = 200)
public static List<Geometry.Entity> ToOpenNest(this ACadSharp.Entities.Ellipse ellipse, double tolerance = 0.001)
{
var lines = new List<Geometry.Line>();
var center = new Vector(ellipse.Center.X, ellipse.Center.Y);
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y);
var majorLength = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var minorLength = majorLength * ellipse.RadiusRatio;
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var semiMinor = semiMajor * ellipse.RadiusRatio;
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
var startParam = ellipse.StartParameter;
var endParam = ellipse.EndParameter;
if (endParam <= startParam)
endParam += System.Math.PI * 2.0;
var step = (endParam - startParam) / precision;
var points = new List<Vector>();
for (var i = 0; i <= precision; i++)
{
var t = startParam + step * i;
var x = majorLength * System.Math.Cos(t);
var y = minorLength * System.Math.Sin(t);
// Rotate by the major axis angle and translate to center
var cos = System.Math.Cos(rotation);
var sin = System.Math.Sin(rotation);
var px = center.X + x * cos - y * sin;
var py = center.Y + x * sin + y * cos;
points.Add(new Vector(px, py));
}
var layer = ellipse.Layer.ToOpenNest();
var color = ellipse.ResolveColor();
var lineTypeName = ellipse.ResolveLineTypeName();
for (var i = 0; i < points.Count - 1; i++)
var entities = EllipseConverter.Convert(center, semiMajor, semiMinor, rotation,
startParam, endParam, tolerance);
foreach (var entity in entities)
{
lines.Add(new Geometry.Line(points[i], points[i + 1])
{
Layer = layer,
Color = color,
LineTypeName = lineTypeName
});
entity.Layer = layer;
entity.Color = color;
entity.LineTypeName = lineTypeName;
}
// Close the ellipse if it's a full ellipse
if (lines.Count >= 2)
{
var first = lines.First();
var last = lines.Last();
lines.Add(new Geometry.Line(last.EndPoint, first.StartPoint)
{
Layer = layer,
Color = color,
LineTypeName = lineTypeName
});
}
return lines;
return entities;
}
public static Geometry.Layer ToOpenNest(this ACadSharp.Tables.Layer layer)
@@ -29,12 +29,12 @@ public sealed class CincinnatiPartSubprogramWriter
public void Write(TextWriter w, Program normalizedProgram, string drawingName,
int subNumber, string cutLibrary, string etchLibrary, double sheetDiagonal)
{
var allFeatures = SplitFeatures(normalizedProgram.Codes);
var allFeatures = FeatureUtils.SplitByRapids(normalizedProgram.Codes);
if (allFeatures.Count == 0)
return;
// Classify and order: etch features first, then cut features
var ordered = OrderFeatures(allFeatures);
var ordered = FeatureUtils.ClassifyAndOrder(allFeatures);
w.WriteLine("(*****************************************************)");
w.WriteLine($":{subNumber}");
@@ -46,7 +46,7 @@ public sealed class CincinnatiPartSubprogramWriter
var featureNumber = i == 0
? _config.FeatureLineNumberStart
: 1000 + i + 1;
var cutDistance = ComputeCutDistance(codes);
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
var ctx = new FeatureContext
{
@@ -70,81 +70,43 @@ public sealed class CincinnatiPartSubprogramWriter
w.WriteLine($"M99 (END OF {drawingName})");
}
internal static List<(List<ICode> codes, bool isEtch)> OrderFeatures(List<List<ICode>> features)
{
var result = new List<(List<ICode>, bool)>();
var etch = new List<List<ICode>>();
var cut = new List<List<ICode>>();
foreach (var f in features)
{
if (CincinnatiSheetWriter.IsFeatureEtch(f))
etch.Add(f);
else
cut.Add(f);
}
foreach (var f in etch)
result.Add((f, true));
foreach (var f in cut)
result.Add((f, false));
return result;
}
/// <summary>
/// Creates a sub-program key for matching parts to their sub-programs.
/// </summary>
internal static (int drawingId, long rotationKey) SubprogramKey(Part part) =>
(part.BaseDrawing.Id, (long)System.Math.Round(part.Rotation * 1e6));
internal static List<List<ICode>> SplitFeatures(List<ICode> codes)
/// <summary>
/// Scans all plates and builds a mapping of unique part geometries to sub-program numbers,
/// along with their normalized programs for writing.
/// </summary>
internal static (Dictionary<(int, long), int> mapping, List<(int subNum, string name, Program program)> entries)
BuildRegistry(IEnumerable<Plate> plates, int startNumber)
{
var features = new List<List<ICode>>();
List<ICode> current = null;
var mapping = new Dictionary<(int, long), int>();
var entries = new List<(int, string, Program)>();
var nextSubNum = startNumber;
foreach (var code in codes)
foreach (var plate in plates)
{
if (code is RapidMove)
foreach (var part in plate.Parts)
{
if (current != null)
features.Add(current);
current = new List<ICode> { code };
}
else
{
current ??= new List<ICode>();
current.Add(code);
if (part.BaseDrawing.IsCutOff) continue;
var key = SubprogramKey(part);
if (!mapping.ContainsKey(key))
{
var subNum = nextSubNum++;
mapping[key] = subNum;
var pgm = part.Program.Clone() as Program;
var bbox = pgm.BoundingBox();
pgm.Offset(-bbox.Location.X, -bbox.Location.Y);
entries.Add((subNum, part.BaseDrawing.Name, pgm));
}
}
}
if (current != null && current.Count > 0)
features.Add(current);
return features;
}
internal static double ComputeCutDistance(List<ICode> codes)
{
var distance = 0.0;
var currentPos = Vector.Zero;
foreach (var code in codes)
{
if (code is RapidMove rapid)
currentPos = rapid.EndPoint;
else if (code is LinearMove linear)
{
distance += currentPos.DistanceTo(linear.EndPoint);
currentPos = linear.EndPoint;
}
else if (code is ArcMove arc)
{
distance += currentPos.DistanceTo(arc.EndPoint);
currentPos = arc.EndPoint;
}
}
return distance;
return (mapping, entries);
}
}
@@ -84,32 +84,7 @@ namespace OpenNest.Posts.Cincinnati
List<(int subNum, string name, Program program)> subprogramEntries = null;
if (Config.UsePartSubprograms)
{
partSubprograms = new Dictionary<(int, long), int>();
subprogramEntries = new List<(int, string, Program)>();
var nextSubNum = Config.PartSubprogramStart;
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
if (part.BaseDrawing.IsCutOff) continue;
var key = CincinnatiPartSubprogramWriter.SubprogramKey(part);
if (!partSubprograms.ContainsKey(key))
{
var subNum = nextSubNum++;
partSubprograms[key] = subNum;
// Create normalized program at origin
var pgm = part.Program.Clone() as Program;
var bbox = pgm.BoundingBox();
pgm.Offset(-bbox.Location.X, -bbox.Location.Y);
subprogramEntries.Add((subNum, part.BaseDrawing.Name, pgm));
}
}
}
}
(partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart);
// 5. Create writers
var preamble = new CincinnatiPreambleWriter(Config);
@@ -4,7 +4,6 @@ using System.IO;
using System.Linq;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.Cincinnati;
@@ -128,7 +127,7 @@ public sealed class CincinnatiSheetWriter
else
{
// Inline features for cutoffs or parts without sub-programs
var features = SplitAndOrderFeatures(part);
var features = FeatureUtils.SplitAndClassify(part);
for (var f = 0; f < features.Count; f++)
{
var (codes, isEtch) = features[f];
@@ -137,7 +136,7 @@ public sealed class CincinnatiSheetWriter
: 1000 + featureIndex + 1;
var isLastFeature = isLastPart && f == features.Count - 1;
var cutDistance = ComputeCutDistance(codes);
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
var ctx = new FeatureContext
{
@@ -205,7 +204,7 @@ public sealed class CincinnatiSheetWriter
var features = new List<(Part part, List<ICode> codes, bool isEtch)>();
foreach (var part in allParts)
{
var partFeatures = SplitAndOrderFeatures(part);
var partFeatures = FeatureUtils.SplitAndClassify(part);
foreach (var (codes, isEtch) in partFeatures)
features.Add((part, codes, isEtch));
}
@@ -224,7 +223,7 @@ public sealed class CincinnatiSheetWriter
? _config.FeatureLineNumberStart
: 1000 + i + 1;
var cutDistance = ComputeCutDistance(codes);
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
var ctx = new FeatureContext
{
@@ -246,91 +245,4 @@ public sealed class CincinnatiSheetWriter
}
}
/// <summary>
/// Splits a part's program into features (by rapids), classifies each as etch or cut,
/// and orders etch features before cut features.
/// </summary>
public static List<(List<ICode> codes, bool isEtch)> SplitAndOrderFeatures(Part part)
{
var etchFeatures = new List<List<ICode>>();
var cutFeatures = new List<List<ICode>>();
List<ICode> current = null;
foreach (var code in part.Program.Codes)
{
if (code is RapidMove)
{
if (current != null)
ClassifyAndAdd(current, etchFeatures, cutFeatures);
current = new List<ICode> { code };
}
else
{
current ??= new List<ICode>();
current.Add(code);
}
}
if (current != null && current.Count > 0)
ClassifyAndAdd(current, etchFeatures, cutFeatures);
// Etch features first, then cut features
var result = new List<(List<ICode>, bool)>();
foreach (var f in etchFeatures)
result.Add((f, true));
foreach (var f in cutFeatures)
result.Add((f, false));
return result;
}
private static void ClassifyAndAdd(List<ICode> codes,
List<List<ICode>> etchFeatures, List<List<ICode>> cutFeatures)
{
if (IsFeatureEtch(codes))
etchFeatures.Add(codes);
else
cutFeatures.Add(codes);
}
/// <summary>
/// A feature is etch if any non-rapid move has LayerType.Scribe.
/// </summary>
public static bool IsFeatureEtch(List<ICode> codes)
{
foreach (var code in codes)
{
if (code is LinearMove linear && linear.Layer == LayerType.Scribe)
return true;
if (code is ArcMove arc && arc.Layer == LayerType.Scribe)
return true;
}
return false;
}
private static double ComputeCutDistance(List<ICode> codes)
{
var distance = 0.0;
var currentPos = Vector.Zero;
foreach (var code in codes)
{
if (code is RapidMove rapid)
{
currentPos = rapid.EndPoint;
}
else if (code is LinearMove linear)
{
distance += currentPos.DistanceTo(linear.EndPoint);
currentPos = linear.EndPoint;
}
else if (code is ArcMove arc)
{
distance += currentPos.DistanceTo(arc.EndPoint);
currentPos = arc.EndPoint;
}
}
return distance;
}
}
+115
View File
@@ -0,0 +1,115 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.Cincinnati;
/// <summary>
/// Shared utilities for splitting CNC programs into features and classifying them.
/// </summary>
public static class FeatureUtils
{
/// <summary>
/// Splits a flat list of codes into feature groups, breaking on rapid moves.
/// Each feature starts with a rapid move followed by cutting/etching moves.
/// </summary>
public static List<List<ICode>> SplitByRapids(List<ICode> codes)
{
var features = new List<List<ICode>>();
List<ICode> current = null;
foreach (var code in codes)
{
if (code is RapidMove)
{
if (current != null)
features.Add(current);
current = new List<ICode> { code };
}
else
{
current ??= new List<ICode>();
current.Add(code);
}
}
if (current != null && current.Count > 0)
features.Add(current);
return features;
}
/// <summary>
/// Classifies features as etch or cut and orders etch features before cut features.
/// </summary>
public static List<(List<ICode> codes, bool isEtch)> ClassifyAndOrder(List<List<ICode>> features)
{
var result = new List<(List<ICode>, bool)>();
var etch = new List<List<ICode>>();
var cut = new List<List<ICode>>();
foreach (var f in features)
{
if (IsEtch(f))
etch.Add(f);
else
cut.Add(f);
}
foreach (var f in etch)
result.Add((f, true));
foreach (var f in cut)
result.Add((f, false));
return result;
}
/// <summary>
/// Splits a part's program into features by rapids, classifies each as etch or cut,
/// and orders etch features before cut features.
/// </summary>
public static List<(List<ICode> codes, bool isEtch)> SplitAndClassify(Part part) =>
ClassifyAndOrder(SplitByRapids(part.Program.Codes));
/// <summary>
/// Returns true if any non-rapid move in the feature has LayerType.Scribe.
/// </summary>
public static bool IsEtch(List<ICode> codes)
{
foreach (var code in codes)
{
if (code is LinearMove linear && linear.Layer == LayerType.Scribe)
return true;
if (code is ArcMove arc && arc.Layer == LayerType.Scribe)
return true;
}
return false;
}
/// <summary>
/// Computes the total cut distance of a feature by summing segment lengths.
/// </summary>
public static double ComputeCutDistance(List<ICode> codes)
{
var distance = 0.0;
var currentPos = Vector.Zero;
foreach (var code in codes)
{
if (code is RapidMove rapid)
currentPos = rapid.EndPoint;
else if (code is LinearMove linear)
{
distance += currentPos.DistanceTo(linear.EndPoint);
currentPos = linear.EndPoint;
}
else if (code is ArcMove arc)
{
distance += currentPos.DistanceTo(arc.EndPoint);
currentPos = arc.EndPoint;
}
}
return distance;
}
}
@@ -29,6 +29,76 @@ public class SolidWorksBendDetectorTests
Assert.Empty(bends);
}
[Fact]
public void EllipseConverter_ProducesArcsDirectly()
{
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT11 Test.dxf");
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
var importer = new OpenNest.IO.DxfImporter { SplinePrecision = 200 };
var result = importer.Import(path);
// EllipseConverter now produces arcs directly during import,
// so the imported entities should contain Arc instances from the ellipses
var arcCount = result.Entities.Count(e => e is OpenNest.Geometry.Arc);
Assert.True(arcCount > 0, "Expected arcs from ellipse conversion");
// The GeometrySimplifier should find few or no line runs to simplify,
// because ellipses are already converted to arcs at import time
var shape = new OpenNest.Geometry.Shape();
shape.Entities.AddRange(result.Entities);
var simplifier = new OpenNest.Geometry.GeometrySimplifier();
var candidates = simplifier.Analyze(shape);
Assert.True(candidates.Count <= 10,
$"Expected <=10 simplifier candidates but got {candidates.Count}");
}
[Fact]
public void Import_TrimmedEllipse_NoClosingChord()
{
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT11.dxf");
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
var importer = new OpenNest.IO.DxfImporter();
var result = importer.Import(path);
// The DXF has 2 trimmed ellipses forming an oblong slot.
// Trimmed ellipses must not generate a closing chord line.
// EllipseConverter now produces arcs instead of line segments,
// changing the entity count. Verify arcs are present and no
// spurious closing chord exists.
var arcCount = result.Entities.Count(e => e is OpenNest.Geometry.Arc);
var circleCount = result.Entities.Count(e => e is OpenNest.Geometry.Circle);
Assert.True(arcCount > 0, "Expected arcs from ellipse conversion");
Assert.True(circleCount >= 7, $"Expected at least 7 circles, got {circleCount}");
Assert.Equal(115, result.Entities.Count);
}
[Fact]
public void DetectBends_SplitBendLine_PropagatesNote()
{
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT23.dxf");
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
using var reader = new DxfReader(path);
var doc = reader.Read();
var detector = new SolidWorksBendDetector();
var bends = detector.DetectBends(doc);
Assert.Equal(5, bends.Count);
Assert.All(bends, b =>
{
Assert.NotNull(b.NoteText);
Assert.Equal(BendDirection.Up, b.Direction);
Assert.Equal(90.0, b.Angle);
Assert.Equal(0.125, b.Radius);
});
}
[Fact]
public void DetectBends_RealDxf_ParsesNotesCorrectly()
{
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -152,7 +152,7 @@ public class CincinnatiSheetWriterTests
new LinearMove(1, 1) { Layer = LayerType.Scribe }
};
Assert.True(CincinnatiSheetWriter.IsFeatureEtch(codes));
Assert.True(FeatureUtils.IsEtch(codes));
}
[Fact]
@@ -165,7 +165,7 @@ public class CincinnatiSheetWriterTests
new LinearMove(1, 1) { Layer = LayerType.Cut }
};
Assert.False(CincinnatiSheetWriter.IsFeatureEtch(codes));
Assert.False(FeatureUtils.IsEtch(codes));
}
[Fact]
@@ -176,7 +176,7 @@ public class CincinnatiSheetWriterTests
new RapidMove(0, 0)
};
Assert.False(CincinnatiSheetWriter.IsFeatureEtch(codes));
Assert.False(FeatureUtils.IsEtch(codes));
}
private static Program CreateSimpleProgram()
+293
View File
@@ -0,0 +1,293 @@
using OpenNest.Geometry;
using OpenNest.Math;
using Xunit;
using System.Linq;
namespace OpenNest.Tests;
public class EllipseConverterTests
{
private const double Tol = 1e-10;
[Fact]
public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd()
{
var p = EllipseConverter.EvaluatePoint(10, 5, 0, new Vector(0, 0), 0);
Assert.InRange(p.X, 10 - Tol, 10 + Tol);
Assert.InRange(p.Y, -Tol, Tol);
}
[Fact]
public void EvaluatePoint_AtHalfPi_ReturnsMinorAxisEnd()
{
var p = EllipseConverter.EvaluatePoint(10, 5, 0, new Vector(0, 0), System.Math.PI / 2);
Assert.InRange(p.X, -Tol, Tol);
Assert.InRange(p.Y, 5 - Tol, 5 + Tol);
}
[Fact]
public void EvaluatePoint_WithRotation_RotatesCorrectly()
{
var p = EllipseConverter.EvaluatePoint(10, 5, System.Math.PI / 2, new Vector(0, 0), 0);
Assert.InRange(p.X, -Tol, Tol);
Assert.InRange(p.Y, 10 - Tol, 10 + Tol);
}
[Fact]
public void EvaluatePoint_WithCenter_TranslatesCorrectly()
{
var p = EllipseConverter.EvaluatePoint(10, 5, 0, new Vector(100, 200), 0);
Assert.InRange(p.X, 110 - Tol, 110 + Tol);
Assert.InRange(p.Y, 200 - Tol, 200 + Tol);
}
[Fact]
public void EvaluateTangent_AtZero_PointsUp()
{
var t = EllipseConverter.EvaluateTangent(10, 5, 0, 0);
Assert.InRange(t.X, -Tol, Tol);
Assert.True(t.Y > 0);
}
[Fact]
public void EvaluateNormal_AtZero_PointsInward()
{
var n = EllipseConverter.EvaluateNormal(10, 5, 0, 0);
Assert.True(n.X < 0);
Assert.InRange(n.Y, -Tol, Tol);
}
[Fact]
public void IntersectNormals_PerpendicularNormals_FindsCenter()
{
var p1 = new Vector(5, 0);
var n1 = new Vector(-1, 0);
var p2 = new Vector(0, 5);
var n2 = new Vector(0, -1);
var center = EllipseConverter.IntersectNormals(p1, n1, p2, n2);
Assert.InRange(center.X, -Tol, Tol);
Assert.InRange(center.Y, -Tol, Tol);
}
[Fact]
public void IntersectNormals_ParallelNormals_ReturnsInvalid()
{
var p1 = new Vector(0, 0);
var n1 = new Vector(1, 0);
var p2 = new Vector(0, 5);
var n2 = new Vector(1, 0);
var center = EllipseConverter.IntersectNormals(p1, n1, p2, n2);
Assert.False(center.IsValid());
}
[Fact]
public void Convert_Circle_ProducesOneOrTwoArcs()
{
var result = EllipseConverter.Convert(
new Vector(0, 0), semiMajor: 10, semiMinor: 10, rotation: 0,
startParam: 0, endParam: Angle.TwoPI, tolerance: 0.001);
Assert.All(result, e => Assert.IsType<Arc>(e));
Assert.InRange(result.Count, 1, 4);
}
[Fact]
public void Convert_ModerateEllipse_AllArcsWithinTolerance()
{
var a = 10.0;
var b = 7.0;
var tolerance = 0.001;
var result = EllipseConverter.Convert(
new Vector(0, 0), a, b, rotation: 0,
startParam: 0, endParam: Angle.TwoPI, tolerance: tolerance);
Assert.True(result.Count >= 4, $"Expected at least 4 arcs, got {result.Count}");
Assert.All(result, e => Assert.IsType<Arc>(e));
foreach (var entity in result)
{
var arc = (Arc)entity;
var maxDev = MaxDeviationFromEllipse(arc, new Vector(0, 0), a, b, 0, 50);
Assert.True(maxDev <= tolerance,
$"Arc at center ({arc.Center.X:F4},{arc.Center.Y:F4}) r={arc.Radius:F4} " +
$"deviates {maxDev:F6} from ellipse (tolerance={tolerance})");
}
}
[Fact]
public void Convert_HighlyEccentricEllipse_ProducesMoreArcs()
{
var a = 20.0;
var b = 3.0;
var tolerance = 0.001;
var result = EllipseConverter.Convert(
new Vector(0, 0), a, b, rotation: 0,
startParam: 0, endParam: Angle.TwoPI, tolerance: tolerance);
Assert.True(result.Count >= 8, $"Expected at least 8 arcs for eccentric ellipse, got {result.Count}");
Assert.All(result, e => Assert.IsType<Arc>(e));
foreach (var entity in result)
{
var arc = (Arc)entity;
var maxDev = MaxDeviationFromEllipse(arc, new Vector(0, 0), a, b, 0, 50);
Assert.True(maxDev <= tolerance,
$"Deviation {maxDev:F6} exceeds tolerance {tolerance}");
}
}
[Fact]
public void Convert_PartialEllipse_CoversArcOnly()
{
var a = 10.0;
var b = 5.0;
var tolerance = 0.001;
var result = EllipseConverter.Convert(
new Vector(0, 0), a, b, rotation: 0,
startParam: 0, endParam: System.Math.PI / 2, tolerance: tolerance);
Assert.NotEmpty(result);
Assert.All(result, e => Assert.IsType<Arc>(e));
var firstArc = (Arc)result[0];
var sp = firstArc.StartPoint();
Assert.InRange(sp.X, a - 0.01, a + 0.01);
Assert.InRange(sp.Y, -0.01, 0.01);
var lastArc = (Arc)result[^1];
var ep = lastArc.EndPoint();
Assert.InRange(ep.X, -0.01, 0.01);
Assert.InRange(ep.Y, b - 0.01, b + 0.01);
}
[Fact]
public void Convert_EndpointContinuity_ArcsConnect()
{
var result = EllipseConverter.Convert(
new Vector(5, 10), semiMajor: 15, semiMinor: 8, rotation: 0.5,
startParam: 0, endParam: Angle.TwoPI, tolerance: 0.001);
for (var i = 0; i < result.Count - 1; i++)
{
var current = (Arc)result[i];
var next = (Arc)result[i + 1];
var gap = current.EndPoint().DistanceTo(next.StartPoint());
Assert.True(gap < 0.001,
$"Gap of {gap:F6} between arc {i} and arc {i + 1}");
}
var lastArc = (Arc)result[^1];
var firstArc = (Arc)result[0];
var closingGap = lastArc.EndPoint().DistanceTo(firstArc.StartPoint());
Assert.True(closingGap < 0.001,
$"Closing gap of {closingGap:F6}");
}
[Fact]
public void Convert_WithRotationAndOffset_ProducesValidArcs()
{
var center = new Vector(50, -30);
var rotation = System.Math.PI / 3;
var a = 12.0;
var b = 6.0;
var tolerance = 0.001;
var result = EllipseConverter.Convert(center, a, b, rotation,
startParam: 0, endParam: Angle.TwoPI, tolerance: tolerance);
Assert.NotEmpty(result);
foreach (var entity in result)
{
var arc = (Arc)entity;
var maxDev = MaxDeviationFromEllipse(arc, center, a, b, rotation, 50);
Assert.True(maxDev <= tolerance,
$"Deviation {maxDev:F6} exceeds tolerance {tolerance}");
}
}
[Fact]
public void DxfImporter_EllipseInDxf_ProducesArcsNotLines()
{
// Create a DXF in memory with an ellipse and verify import produces arcs
var doc = new ACadSharp.CadDocument();
var ellipse = new ACadSharp.Entities.Ellipse
{
Center = new CSMath.XYZ(0, 0, 0),
MajorAxisEndPoint = new CSMath.XYZ(10, 0, 0),
RadiusRatio = 0.6,
StartParameter = 0,
EndParameter = System.Math.PI * 2
};
doc.Entities.Add(ellipse);
// Write to temp file and re-import
var tempPath = System.IO.Path.GetTempFileName() + ".dxf";
try
{
using (var stream = System.IO.File.Create(tempPath))
using (var writer = new ACadSharp.IO.DxfWriter(stream, doc, false))
writer.Write();
var importer = new OpenNest.IO.DxfImporter { SplinePrecision = 200 };
var result = importer.Import(tempPath);
var arcCount = result.Entities.Count(e => e is Arc);
var lineCount = result.Entities.Count(e => e is Line);
// Should have arcs, not hundreds of lines
Assert.True(arcCount >= 4, $"Expected at least 4 arcs, got {arcCount}");
Assert.Equal(0, lineCount);
}
finally
{
if (System.IO.File.Exists(tempPath))
System.IO.File.Delete(tempPath);
}
}
private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter,
double semiMajor, double semiMinor, double rotation, int samples)
{
var maxDev = 0.0;
var sweep = arc.SweepAngle();
var startAngle = arc.StartAngle;
if (arc.IsReversed)
startAngle = arc.EndAngle;
for (var i = 0; i <= samples; i++)
{
var frac = (double)i / samples;
var angle = startAngle + frac * sweep;
var px = arc.Center.X + arc.Radius * System.Math.Cos(angle);
var py = arc.Center.Y + arc.Radius * System.Math.Sin(angle);
var arcPoint = new Vector(px, py);
// Coarse search over 1000 samples
var bestT = 0.0;
var minDist = double.MaxValue;
for (var j = 0; j <= 1000; j++)
{
var t = (double)j / 1000 * Angle.TwoPI;
var ep2 = EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t);
var dist = arcPoint.DistanceTo(ep2);
if (dist < minDist) { minDist = dist; bestT = t; }
}
// Refine with local bisection around bestT
var lo = bestT - Angle.TwoPI / 1000;
var hi = bestT + Angle.TwoPI / 1000;
for (var r = 0; r < 20; r++)
{
var t1 = lo + (hi - lo) / 3;
var t2 = lo + 2 * (hi - lo) / 3;
var d1 = arcPoint.DistanceTo(EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1));
var d2 = arcPoint.DistanceTo(EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t2));
if (d1 < d2) hi = t2; else lo = t1;
}
var bestDist = arcPoint.DistanceTo(EllipseConverter.EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, (lo + hi) / 2));
if (bestDist > maxDev) maxDev = bestDist;
}
return maxDev;
}
}
+83
View File
@@ -0,0 +1,83 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using Xunit.Abstractions;
namespace OpenNest.Tests;
public class EngineOverlapTests
{
private const string DxfPath = @"C:\Users\AJ\Desktop\Templates\4526 A14 PT15.dxf";
private readonly ITestOutputHelper _output;
public EngineOverlapTests(ITestOutputHelper output)
{
_output = output;
}
private static Drawing ImportDxf()
{
var importer = new DxfImporter();
importer.GetGeometry(DxfPath, out var geometry);
var pgm = ConvertGeometry.ToProgram(geometry);
return new Drawing("PT15", pgm);
}
[Theory]
[InlineData("Default")]
[InlineData("Strip")]
[InlineData("Vertical Remnant")]
[InlineData("Horizontal Remnant")]
public void FillPlate_NoOverlaps(string engineName)
{
var drawing = ImportDxf();
var plate = new Plate(60, 120);
NestEngineRegistry.ActiveEngineName = engineName;
var engine = NestEngineRegistry.Create(plate);
var item = new NestItem { Drawing = drawing };
var success = engine.Fill(item);
_output.WriteLine($"Engine: {engine.Name}, Parts: {plate.Parts.Count}, Utilization: {plate.Utilization():P1}");
if (engine is DefaultNestEngine defaultEngine)
{
_output.WriteLine($"Winner phase: {defaultEngine.WinnerPhase}");
foreach (var pr in defaultEngine.PhaseResults)
_output.WriteLine($" Phase {pr.Phase}: {pr.PartCount} parts in {pr.TimeMs}ms");
}
// Show rotation distribution
var rotGroups = plate.Parts
.GroupBy(p => System.Math.Round(OpenNest.Math.Angle.ToDegrees(p.Rotation), 1))
.OrderBy(g => g.Key);
foreach (var g in rotGroups)
_output.WriteLine($" Rotation {g.Key:F1}°: {g.Count()} parts");
var hasOverlaps = plate.HasOverlappingParts(out var collisionPoints);
_output.WriteLine($"Overlaps: {hasOverlaps} ({collisionPoints.Count} collision pts)");
if (hasOverlaps)
{
for (var i = 0; i < System.Math.Min(collisionPoints.Count, 10); i++)
_output.WriteLine($" ({collisionPoints[i].X:F2}, {collisionPoints[i].Y:F2})");
}
Assert.False(hasOverlaps,
$"Engine '{engineName}' produced {collisionPoints.Count} collision point(s) with {plate.Parts.Count} parts");
}
[Fact]
public void AdjacentParts_ShouldNotOverlap()
{
var plate = TestHelpers.MakePlate(60, 120,
TestHelpers.MakePartAt(0, 0, 10),
TestHelpers.MakePartAt(10, 0, 10));
var hasOverlaps = plate.HasOverlappingParts(out var pts);
_output.WriteLine($"Adjacent squares: overlaps={hasOverlaps}, collision count={pts.Count}");
Assert.False(hasOverlaps, "Adjacent edge-touching parts should not be reported as overlapping");
}
}
+130
View File
@@ -0,0 +1,130 @@
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests;
public class GeometrySimplifierTests
{
[Fact]
public void Analyze_LinesFromSemicircle_FindsOneCandidate()
{
// Create 20 lines approximating a semicircle of radius 10
var arc = new Arc(new Vector(0, 0), 10, 0, System.Math.PI, false);
var points = arc.ToPoints(20);
var shape = new Shape();
for (var i = 0; i < points.Count - 1; i++)
shape.Entities.Add(new Line(points[i], points[i + 1]));
var simplifier = new GeometrySimplifier { Tolerance = 0.1 };
var candidates = simplifier.Analyze(shape);
Assert.Single(candidates);
Assert.Equal(0, candidates[0].StartIndex);
Assert.Equal(19, candidates[0].EndIndex);
Assert.Equal(20, candidates[0].LineCount);
Assert.InRange(candidates[0].FittedArc.Radius, 9.5, 10.5);
Assert.True(candidates[0].MaxDeviation <= 0.1);
}
[Fact]
public void Analyze_TooFewLines_ReturnsNoCandidates()
{
// Only 2 consecutive lines — below MinLines threshold
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(1, 1)));
shape.Entities.Add(new Line(new Vector(1, 1), new Vector(2, 0)));
var simplifier = new GeometrySimplifier { Tolerance = 0.1, MinLines = 3 };
var candidates = simplifier.Analyze(shape);
Assert.Empty(candidates);
}
[Fact]
public void Analyze_MixedEntitiesWithArc_FindsSeparateCandidates()
{
// Lines on one curve, then an arc at a different center, then lines on another curve
// The arc is included in the run but can't merge with lines on different curves
var shape = new Shape();
// First run: 5 lines on a curve
var arc1 = new Arc(new Vector(0, 0), 10, 0, System.Math.PI / 2, false);
var pts1 = arc1.ToPoints(5);
for (var i = 0; i < pts1.Count - 1; i++)
shape.Entities.Add(new Line(pts1[i], pts1[i + 1]));
// An existing arc entity (breaks the run)
shape.Entities.Add(new Arc(new Vector(20, 0), 5, 0, System.Math.PI, false));
// Second run: 4 lines on a different curve
var arc2 = new Arc(new Vector(30, 0), 8, 0, System.Math.PI / 3, false);
var pts2 = arc2.ToPoints(4);
for (var i = 0; i < pts2.Count - 1; i++)
shape.Entities.Add(new Line(pts2[i], pts2[i + 1]));
var simplifier = new GeometrySimplifier { Tolerance = 0.5, MinLines = 3 };
var candidates = simplifier.Analyze(shape);
Assert.Equal(2, candidates.Count);
// First candidate covers indices 0-4 (5 lines)
Assert.Equal(0, candidates[0].StartIndex);
Assert.Equal(4, candidates[0].EndIndex);
// Second candidate covers indices 6-9 (4 lines, after the arc at index 5)
Assert.Equal(6, candidates[1].StartIndex);
Assert.Equal(9, candidates[1].EndIndex);
}
[Fact]
public void Apply_SingleCandidate_ReplacesLinesWithArc()
{
// 20 lines approximating a semicircle
var arc = new Arc(new Vector(0, 0), 10, 0, System.Math.PI, false);
var points = arc.ToPoints(20);
var shape = new Shape();
for (var i = 0; i < points.Count - 1; i++)
shape.Entities.Add(new Line(points[i], points[i + 1]));
var simplifier = new GeometrySimplifier { Tolerance = 0.1 };
var candidates = simplifier.Analyze(shape);
var result = simplifier.Apply(shape, candidates);
Assert.Single(result.Entities);
Assert.IsType<Arc>(result.Entities[0]);
}
[Fact]
public void Apply_OnlySelectedCandidates_LeavesUnselectedAsLines()
{
// Two runs of lines with an arc between them
var shape = new Shape();
var arc1 = new Arc(new Vector(0, 0), 10, 0, System.Math.PI / 2, false);
var pts1 = arc1.ToPoints(5);
for (var i = 0; i < pts1.Count - 1; i++)
shape.Entities.Add(new Line(pts1[i], pts1[i + 1]));
shape.Entities.Add(new Arc(new Vector(20, 0), 5, 0, System.Math.PI, false));
var arc2 = new Arc(new Vector(30, 0), 8, 0, System.Math.PI / 3, false);
var pts2 = arc2.ToPoints(4);
for (var i = 0; i < pts2.Count - 1; i++)
shape.Entities.Add(new Line(pts2[i], pts2[i + 1]));
var simplifier = new GeometrySimplifier { Tolerance = 0.5, MinLines = 3 };
var candidates = simplifier.Analyze(shape);
// Deselect the first candidate
candidates[0].IsSelected = false;
var result = simplifier.Apply(shape, candidates);
// First run (5 lines) stays as lines + middle arc + second run replaced by arc
// 5 original lines + 1 original arc + 1 fitted arc = 7 entities
Assert.Equal(7, result.Entities.Count);
// First 5 should be lines
for (var i = 0; i < 5; i++)
Assert.IsType<Line>(result.Entities[i]);
// Index 5 is the original arc
Assert.IsType<Arc>(result.Entities[5]);
// Index 6 is the fitted arc replacing the second run
Assert.IsType<Arc>(result.Entities[6]);
}
}
+132
View File
@@ -0,0 +1,132 @@
using OpenNest.Geometry;
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests;
public class SplineConverterTests
{
[Fact]
public void Convert_SemicirclePoints_ProducesSingleArc()
{
var points = new System.Collections.Generic.List<Vector>();
for (var i = 0; i <= 50; i++)
{
var t = System.Math.PI * i / 50;
points.Add(new Vector(10 * System.Math.Cos(t), 10 * System.Math.Sin(t)));
}
var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);
Assert.Single(result);
Assert.IsType<Arc>(result[0]);
var arc = (Arc)result[0];
Assert.InRange(arc.Radius, 9.99, 10.01);
}
[Fact]
public void Convert_StraightLinePoints_ProducesSingleLine()
{
var points = new System.Collections.Generic.List<Vector>();
for (var i = 0; i <= 10; i++)
points.Add(new Vector(i, 2 * i + 1));
var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);
Assert.All(result, e => Assert.IsType<Line>(e));
}
[Fact]
public void Convert_SCurve_ProducesMultipleArcs()
{
var points = new System.Collections.Generic.List<Vector>();
for (var i = 0; i <= 30; i++)
{
var t = System.Math.PI * i / 30;
points.Add(new Vector(10 * System.Math.Cos(t), 10 * System.Math.Sin(t)));
}
for (var i = 1; i <= 30; i++)
{
var t = -System.Math.PI * i / 30;
points.Add(new Vector(-20 + 10 * System.Math.Cos(t), 10 * System.Math.Sin(t)));
}
var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);
var arcCount = result.Count(e => e is Arc);
Assert.True(arcCount >= 2, $"Expected at least 2 arcs, got {arcCount}");
}
[Fact]
public void Convert_TwoPoints_ProducesSingleLine()
{
var points = new System.Collections.Generic.List<Vector>
{
new Vector(0, 0),
new Vector(10, 5)
};
var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);
Assert.Single(result);
Assert.IsType<Line>(result[0]);
}
[Fact]
public void Convert_EndpointContinuity_EntitiesConnect()
{
var points = new System.Collections.Generic.List<Vector>();
for (var i = 0; i <= 80; i++)
{
var t = Angle.TwoPI * i / 80;
points.Add(new Vector(15 * System.Math.Cos(t), 8 * System.Math.Sin(t)));
}
var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);
for (var i = 0; i < result.Count - 1; i++)
{
var endPt = GetEndPoint(result[i]);
var startPt = GetStartPoint(result[i + 1]);
var gap = endPt.DistanceTo(startPt);
Assert.True(gap < 0.001,
$"Gap of {gap:F6} between entity {i} and {i + 1}");
}
}
[Fact]
public void Convert_EmptyPoints_ReturnsEmpty()
{
var result = SplineConverter.Convert(new System.Collections.Generic.List<Vector>(),
isClosed: false, tolerance: 0.001);
Assert.Empty(result);
}
[Fact]
public void Convert_SinglePoint_ReturnsEmpty()
{
var points = new System.Collections.Generic.List<Vector> { new Vector(5, 5) };
var result = SplineConverter.Convert(points, isClosed: false, tolerance: 0.001);
Assert.Empty(result);
}
private static Vector GetStartPoint(Entity e)
{
return e switch
{
Arc a => a.StartPoint(),
Line l => l.StartPoint,
_ => throw new System.Exception("Unexpected entity type")
};
}
private static Vector GetEndPoint(Entity e)
{
return e switch
{
Arc a => a.EndPoint(),
Line l => l.EndPoint,
_ => throw new System.Exception("Unexpected entity type")
};
}
}
@@ -0,0 +1,219 @@
using ACadSharp.IO;
using OpenNest.Bending;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Shapes;
namespace OpenNest.Tests.Splitting;
public class SplitDxfWriterEtchLayerTests
{
[Fact]
public void Write_DrawingWithUpBend_EtchLinesHaveEtchLayer()
{
// Create a simple rectangular drawing with an up bend
var drawing = new RectangleShape { Name = "TEST", Length = 100, Width = 50 }.GetDrawing();
drawing.Bends = new List<Bend>
{
new Bend
{
StartPoint = new Vector(0, 25),
EndPoint = new Vector(100, 25),
Direction = BendDirection.Up,
Angle = 90,
Radius = 0.06,
NoteText = "UP 90° R0.06"
}
};
var tempPath = Path.Combine(Path.GetTempPath(), $"etch_layer_test_{Guid.NewGuid()}.dxf");
try
{
var writer = new SplitDxfWriter();
writer.Write(tempPath, drawing);
// Re-read the DXF and check entity layers
using var reader = new DxfReader(tempPath);
var doc = reader.Read();
var etchEntities = new List<ACadSharp.Entities.Entity>();
var allEntities = new List<(string LayerName, string Type)>();
foreach (var entity in doc.Entities)
{
var layerName = entity.Layer?.Name ?? "(null)";
allEntities.Add((layerName, entity.GetType().Name));
// Etch lines are short lines along the bend direction at the ends
if (entity is ACadSharp.Entities.Line line)
{
// Check if this line is an etch mark (short, near the bend Y=25)
var midY = (line.StartPoint.Y + line.EndPoint.Y) / 2;
var length = System.Math.Sqrt(
System.Math.Pow(line.EndPoint.X - line.StartPoint.X, 2) +
System.Math.Pow(line.EndPoint.Y - line.StartPoint.Y, 2));
if (System.Math.Abs(midY - 25) < 0.1 && length <= 1.5 && layerName != "BEND")
{
etchEntities.Add(entity);
}
}
}
// Should have etch lines (up bend with length 100 > 3*EtchLength, so 2 etch dashes)
Assert.True(etchEntities.Count >= 2,
$"Expected at least 2 etch lines, found {etchEntities.Count}. " +
$"All entities: {string.Join(", ", allEntities.Select(e => $"{e.Type}@{e.LayerName}"))}");
// ALL etch lines should be on the ETCH layer, not layer 0
foreach (var etch in etchEntities)
{
var layerName = etch.Layer?.Name ?? "(null)";
Assert.Equal("ETCH", layerName);
}
}
finally
{
if (File.Exists(tempPath))
File.Delete(tempPath);
}
}
[Fact]
public void Write_SplitDrawingWithUpBend_EtchLinesHaveEtchLayer()
{
// Create a drawing, split it, then verify etch layers in the split DXFs
var drawing = new RectangleShape { Name = "TEST", Length = 100, Width = 50 }.GetDrawing();
drawing.Bends = new List<Bend>
{
new Bend
{
StartPoint = new Vector(0, 25),
EndPoint = new Vector(100, 25),
Direction = BendDirection.Up,
Angle = 90,
Radius = 0.06,
NoteText = "UP 90° R0.06"
}
};
var splitLines = new List<SplitLine> { new SplitLine(50.0, CutOffAxis.Vertical) };
var parameters = new SplitParameters { Type = SplitType.Straight };
var results = DrawingSplitter.Split(drawing, splitLines, parameters);
Assert.Equal(2, results.Count);
foreach (var splitDrawing in results)
{
// Each split piece should have the bend (clipped to region)
Assert.NotNull(splitDrawing.Bends);
Assert.True(splitDrawing.Bends.Count > 0, $"{splitDrawing.Name} should have bends");
var tempPath = Path.Combine(Path.GetTempPath(), $"split_etch_test_{splitDrawing.Name}_{Guid.NewGuid()}.dxf");
try
{
var writer = new SplitDxfWriter();
writer.Write(tempPath, splitDrawing);
// Re-read and verify
using var reader = new DxfReader(tempPath);
var doc = reader.Read();
var entitySummary = new List<string>();
var etchLayerEntities = new List<ACadSharp.Entities.Entity>();
var layer0Entities = new List<ACadSharp.Entities.Entity>();
foreach (var entity in doc.Entities)
{
var layerName = entity.Layer?.Name ?? "(null)";
entitySummary.Add($"{entity.GetType().Name}@{layerName}");
if (string.Equals(layerName, "ETCH", StringComparison.OrdinalIgnoreCase))
etchLayerEntities.Add(entity);
else if (string.Equals(layerName, "0", StringComparison.OrdinalIgnoreCase))
layer0Entities.Add(entity);
}
// Should have etch entities
Assert.True(etchLayerEntities.Count > 0,
$"{splitDrawing.Name}: No entities on ETCH layer. " +
$"All: {string.Join(", ", entitySummary)}");
// No entities should be on layer 0
Assert.True(layer0Entities.Count == 0,
$"{splitDrawing.Name}: {layer0Entities.Count} entities on layer 0 " +
$"(expected all on CUT/BEND/ETCH). " +
$"All: {string.Join(", ", entitySummary)}");
}
finally
{
if (File.Exists(tempPath))
File.Delete(tempPath);
}
}
}
[Fact]
public void Write_ReImport_EtchEntitiesFilteredFromCutGeometry()
{
// After re-import, ETCH entities should be filtered (like BEND) since
// etch marks are generated from bends, not treated as cut geometry.
var drawing = new RectangleShape { Name = "TEST", Length = 100, Width = 50 }.GetDrawing();
drawing.Bends = new List<Bend>
{
new Bend
{
StartPoint = new Vector(0, 25),
EndPoint = new Vector(100, 25),
Direction = BendDirection.Up,
Angle = 90,
Radius = 0.06,
NoteText = "UP 90° R0.06"
}
};
var splitLines = new List<SplitLine> { new SplitLine(50.0, CutOffAxis.Vertical) };
var parameters = new SplitParameters { Type = SplitType.Straight };
var results = DrawingSplitter.Split(drawing, splitLines, parameters);
foreach (var splitDrawing in results)
{
var tempPath = Path.Combine(Path.GetTempPath(), $"reimport_etch_test_{splitDrawing.Name}_{Guid.NewGuid()}.dxf");
try
{
var writer = new SplitDxfWriter();
writer.Write(tempPath, splitDrawing);
// Re-import via DxfImporter (same path as CadConverterForm)
var importer = new DxfImporter();
var result = importer.Import(tempPath);
// ETCH entities should be filtered during import (like BEND)
var etchEntities = result.Entities
.Where(e => string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase))
.ToList();
var layer0Entities = result.Entities
.Where(e => string.Equals(e.Layer?.Name, "0", StringComparison.OrdinalIgnoreCase))
.ToList();
Assert.True(etchEntities.Count == 0,
$"{splitDrawing.Name}: ETCH entities should be filtered during import, found {etchEntities.Count}");
Assert.True(layer0Entities.Count == 0,
$"{splitDrawing.Name}: {layer0Entities.Count} entities on layer 0 after re-import");
// All imported entities should be on CUT layer (cut geometry only)
Assert.True(result.Entities.Count > 0, $"{splitDrawing.Name}: Should have cut geometry");
Assert.True(result.Entities.All(e => string.Equals(e.Layer?.Name, "CUT", StringComparison.OrdinalIgnoreCase)),
$"{splitDrawing.Name}: All imported entities should be on CUT layer. " +
$"Found: {string.Join(", ", result.Entities.Select(e => e.Layer?.Name ?? "(null)").Distinct())}");
}
finally
{
if (File.Exists(tempPath))
File.Delete(tempPath);
}
}
}
}
+17 -4
View File
@@ -134,7 +134,7 @@ public class StripeFillerTests
}
[Fact]
public void Fill_ProducesPartsForSimpleDrawing()
public void Fill_ProducesNonOverlappingPartsForSimpleDrawing()
{
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
var drawing = MakeRectDrawing(20, 10);
@@ -158,11 +158,19 @@ public class StripeFillerTests
var parts = filler.Fill();
Assert.NotNull(parts);
Assert.True(parts.Count > 0, "Expected parts from stripe fill");
// StripeFiller may return empty if the converged angle produces
// overlapping parts that fail the overlap validation check.
// The important thing is that any returned parts are overlap-free.
if (parts.Count > 0)
{
plate.Parts.AddRange(parts);
var hasOverlaps = plate.HasOverlappingParts(out _);
Assert.False(hasOverlaps, "Stripe fill should not produce overlapping parts");
}
}
[Fact]
public void Fill_VerticalProducesParts()
public void Fill_VerticalProducesNonOverlappingParts()
{
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
var drawing = MakeRectDrawing(20, 10);
@@ -186,7 +194,12 @@ public class StripeFillerTests
var parts = filler.Fill();
Assert.NotNull(parts);
Assert.True(parts.Count > 0, "Expected parts from column fill");
if (parts.Count > 0)
{
plate.Parts.AddRange(parts);
var hasOverlaps = plate.HasOverlappingParts(out _);
Assert.False(hasOverlaps, "Column fill should not produce overlapping parts");
}
}
[Fact]
+110
View File
@@ -0,0 +1,110 @@
using OpenNest.Converters;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Strategies;
using OpenNest.Geometry;
using OpenNest.IO;
using Xunit.Abstractions;
namespace OpenNest.Tests;
public class StrategyOverlapTests
{
private const string DxfPath = @"C:\Users\AJ\Desktop\Templates\4526 A14 PT15.dxf";
private readonly ITestOutputHelper _output;
public StrategyOverlapTests(ITestOutputHelper output)
{
_output = output;
}
private static Drawing ImportDxf()
{
var importer = new DxfImporter();
importer.GetGeometry(DxfPath, out var geometry);
var pgm = ConvertGeometry.ToProgram(geometry);
return new Drawing("PT15", pgm);
}
[Fact]
public void EachStrategy_CheckOverlaps()
{
var drawing = ImportDxf();
_output.WriteLine($"Drawing bbox: {drawing.Program.BoundingBox().Width:F2} x {drawing.Program.BoundingBox().Length:F2}");
var strategies = FillStrategyRegistry.Strategies.ToList();
var item = new NestItem { Drawing = drawing };
var bestRotation = RotationAnalysis.FindBestRotation(item);
var failures = new List<string>();
foreach (var strategy in strategies)
{
var plate = new Plate(60, 120);
var comparer = new DefaultFillComparer();
var policy = new FillPolicy(comparer);
var context = new FillContext
{
Item = item,
WorkArea = plate.WorkArea(),
Plate = plate,
PlateNumber = 0,
Token = System.Threading.CancellationToken.None,
Policy = policy,
};
context.SharedState["BestRotation"] = bestRotation;
context.SharedState["AngleCandidates"] = new AngleCandidateBuilder().Build(
item, bestRotation, context.WorkArea);
var parts = strategy.Fill(context);
var count = parts?.Count ?? 0;
_output.WriteLine($"\n{strategy.GetType().Name} (Phase: {strategy.Phase}, Order: {strategy.Order}): {count} parts");
if (count == 0)
continue;
plate.Parts.AddRange(parts);
_output.WriteLine($" Utilization: {plate.Utilization():P1}");
var rotGroups = parts
.GroupBy(p => System.Math.Round(OpenNest.Math.Angle.ToDegrees(p.Rotation), 1))
.OrderBy(g => g.Key);
foreach (var g in rotGroups)
_output.WriteLine($" Rotation {g.Key:F1}°: {g.Count()} parts");
var hasOverlaps = plate.HasOverlappingParts(out var pts);
_output.WriteLine($" Overlaps: {hasOverlaps} ({pts.Count} collision pts)");
if (hasOverlaps)
{
failures.Add($"{strategy.GetType().Name} ({strategy.Phase}): {pts.Count} collision pts, {count} parts");
// Show overlapping pair details
for (var a = 0; a < parts.Count; a++)
{
for (var b = a + 1; b < parts.Count; b++)
{
var ba = parts[a].BoundingBox;
var bb = parts[b].BoundingBox;
var oX = System.Math.Min(ba.Right, bb.Right) - System.Math.Max(ba.Left, bb.Left);
var oY = System.Math.Min(ba.Top, bb.Top) - System.Math.Max(ba.Bottom, bb.Bottom);
if (oX <= OpenNest.Math.Tolerance.Epsilon || oY <= OpenNest.Math.Tolerance.Epsilon)
continue;
if (parts[a].Intersects(parts[b], out var pairPts) && pairPts.Count > 0)
{
_output.WriteLine($" [{a}] vs [{b}]: {pairPts.Count} pts, bbox overlap: {oX:F4} x {oY:F4}");
_output.WriteLine($" [{a}]: loc=({parts[a].Location.X:F4},{parts[a].Location.Y:F4}) rot={OpenNest.Math.Angle.ToDegrees(parts[a].Rotation):F2}°");
_output.WriteLine($" [{b}]: loc=({parts[b].Location.X:F4},{parts[b].Location.Y:F4}) rot={OpenNest.Math.Angle.ToDegrees(parts[b].Rotation):F2}°");
}
}
}
}
}
_output.WriteLine($"\n=== SUMMARY ===");
foreach (var f in failures)
_output.WriteLine($" OVERLAP: {f}");
Assert.Empty(failures);
}
}
+115 -5
View File
@@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
namespace OpenNest.Controls
@@ -15,6 +16,19 @@ namespace OpenNest.Controls
public List<Bend> Bends = new List<Bend>();
public int SelectedBendIndex = -1;
private HashSet<Entity> simplifierHighlightSet;
public List<Entity> SimplifierHighlight
{
get => simplifierHighlightSet?.ToList();
set => simplifierHighlightSet = value != null ? new HashSet<Entity>(value) : null;
}
public Arc SimplifierPreview { get; set; }
public List<Entity> SimplifierToleranceLeft { get; set; }
public List<Entity> SimplifierToleranceRight { get; set; }
public List<Entity> OriginalEntities { get; set; }
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
@@ -68,10 +82,24 @@ namespace OpenNest.Controls
e.Graphics.TranslateTransform(origin.X, origin.Y);
// Draw original geometry overlay (faded, behind current)
if (OriginalEntities != null)
{
using var origPen = new Pen(Color.FromArgb(50, 255, 140, 40));
foreach (var entity in OriginalEntities)
{
if (!IsEtchLayer(entity.Layer))
DrawEntity(e.Graphics, entity, origPen);
}
}
foreach (var entity in Entities)
{
if (IsEtchLayer(entity.Layer)) continue;
var pen = GetEntityPen(entity.Color);
var isHighlighted = simplifierHighlightSet != null && simplifierHighlightSet.Contains(entity);
var pen = isHighlighted
? GetEntityPen(Color.FromArgb(60, entity.Color))
: GetEntityPen(entity.Color);
DrawEntity(e.Graphics, entity, pen);
}
@@ -84,6 +112,33 @@ namespace OpenNest.Controls
DrawEntity(e.Graphics, entity, pen);
}
DrawEtchMarks(e.Graphics);
if (SimplifierPreview != null)
{
// Draw tolerance zone (offset lines each side of original geometry)
if (SimplifierToleranceLeft != null)
{
using var zonePen = new Pen(Color.FromArgb(40, 100, 200, 100));
foreach (var entity in SimplifierToleranceLeft)
DrawEntity(e.Graphics, entity, zonePen);
foreach (var entity in SimplifierToleranceRight)
DrawEntity(e.Graphics, entity, zonePen);
}
// Draw old geometry (highlighted lines) in orange dashed
if (simplifierHighlightSet != null)
{
using var oldPen = new Pen(Color.FromArgb(180, 255, 160, 50), 1f / ViewScale) { DashPattern = new float[] { 6, 3 } };
foreach (var entity in simplifierHighlightSet)
DrawEntity(e.Graphics, entity, oldPen);
}
// Draw the new arc in bright green
using var previewPen = new Pen(Color.FromArgb(0, 200, 80), 2f / ViewScale);
DrawArc(e.Graphics, SimplifierPreview, previewPen);
}
#if DRAW_OFFSET
var offsetShape = new Shape();
@@ -185,6 +240,46 @@ namespace OpenNest.Controls
private static bool IsEtchLayer(Layer layer) =>
string.Equals(layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase);
private void DrawEtchMarks(Graphics g)
{
if (Bends == null || Bends.Count == 0)
return;
using var etchPen = new Pen(Color.Green, 1.5f);
var etchLength = 1.0;
foreach (var bend in Bends)
{
if (bend.Direction != BendDirection.Up)
continue;
var start = bend.StartPoint;
var end = bend.EndPoint;
var length = bend.Length;
if (length < etchLength * 3.0)
{
var pt1 = PointWorldToGraph(start);
var pt2 = PointWorldToGraph(end);
g.DrawLine(etchPen, pt1, pt2);
}
else
{
var angle = start.AngleTo(end);
var dx = System.Math.Cos(angle) * etchLength;
var dy = System.Math.Sin(angle) * etchLength;
var s1 = PointWorldToGraph(start);
var e1 = PointWorldToGraph(new Vector(start.X + dx, start.Y + dy));
g.DrawLine(etchPen, s1, e1);
var s2 = PointWorldToGraph(end);
var e2 = PointWorldToGraph(new Vector(end.X - dx, end.Y - dy));
g.DrawLine(etchPen, s2, e2);
}
}
}
private void DrawBendLines(Graphics g)
{
if (Bends == null || Bends.Count == 0)
@@ -198,20 +293,26 @@ namespace OpenNest.Controls
{
DashPattern = new float[] { 6, 4 }
};
using var noteFont = new Font("Segoe UI", 9f);
using var noteBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
using var selectedNoteBrush = new SolidBrush(Color.FromArgb(220, 255, 180, 100));
for (var i = 0; i < Bends.Count; i++)
{
var bend = Bends[i];
var pt1 = PointWorldToGraph(bend.StartPoint);
var pt2 = PointWorldToGraph(bend.EndPoint);
var isSelected = i == SelectedBendIndex;
if (i == SelectedBendIndex)
{
if (isSelected)
g.DrawLine(glowPen, pt1, pt2);
}
else
{
g.DrawLine(bendPen, pt1, pt2);
if (!string.IsNullOrEmpty(bend.NoteText))
{
var mid = new PointF((pt1.X + pt2.X) / 2f, (pt1.Y + pt2.Y) / 2f);
g.DrawString(bend.NoteText, noteFont, isSelected ? selectedNoteBrush : noteBrush, mid.X + 4, mid.Y + 4);
}
}
}
@@ -358,6 +459,15 @@ namespace OpenNest.Controls
}
}
public void ClearSimplifierPreview()
{
SimplifierHighlight = null;
SimplifierPreview = null;
SimplifierToleranceLeft = null;
SimplifierToleranceRight = null;
Invalidate();
}
public void ZoomToFit(bool redraw = true)
{
ZoomToArea(Entities.GetBoundingBox(), redraw);
+1
View File
@@ -17,6 +17,7 @@ namespace OpenNest.Controls
public int Quantity { get; set; } = 1;
public string Path { get; set; }
public List<Entity> Entities { get; set; } = new();
public List<Entity> OriginalEntities { get; set; }
public List<Bend> Bends { get; set; } = new();
public Box Bounds { get; set; }
public int EntityCount { get; set; }
+81 -17
View File
@@ -15,6 +15,7 @@ namespace OpenNest.Forms
private void InitializeComponent()
{
mainSplit = new System.Windows.Forms.SplitContainer();
sidebarSplit = new System.Windows.Forms.SplitContainer();
fileList = new OpenNest.Controls.FileListControl();
filterPanel = new OpenNest.Controls.FilterPanel();
@@ -27,11 +28,18 @@ namespace OpenNest.Forms
lblDimensions = new System.Windows.Forms.Label();
lblEntityCount = new System.Windows.Forms.Label();
btnSplit = new System.Windows.Forms.Button();
btnSimplify = new System.Windows.Forms.Button();
btnExportDxf = new System.Windows.Forms.Button();
chkShowOriginal = new System.Windows.Forms.CheckBox();
lblDetect = new System.Windows.Forms.Label();
cboBendDetector = new System.Windows.Forms.ComboBox();
bottomPanel1 = new OpenNest.Controls.BottomPanel();
cancelButton = new System.Windows.Forms.Button();
acceptButton = new System.Windows.Forms.Button();
((System.ComponentModel.ISupportInitialize)mainSplit).BeginInit();
mainSplit.Panel1.SuspendLayout();
mainSplit.Panel2.SuspendLayout();
mainSplit.SuspendLayout();
((System.ComponentModel.ISupportInitialize)sidebarSplit).BeginInit();
sidebarSplit.Panel1.SuspendLayout();
sidebarSplit.Panel2.SuspendLayout();
@@ -40,25 +48,46 @@ namespace OpenNest.Forms
((System.ComponentModel.ISupportInitialize)numQuantity).BeginInit();
bottomPanel1.SuspendLayout();
SuspendLayout();
//
//
// mainSplit
//
mainSplit.Dock = System.Windows.Forms.DockStyle.Fill;
mainSplit.FixedPanel = System.Windows.Forms.FixedPanel.Panel1;
mainSplit.Location = new System.Drawing.Point(0, 0);
mainSplit.Name = "mainSplit";
//
// mainSplit.Panel1
//
mainSplit.Panel1.Controls.Add(sidebarSplit);
mainSplit.Panel1MinSize = 200;
//
// mainSplit.Panel2
//
mainSplit.Panel2.Controls.Add(entityView1);
mainSplit.Panel2.Controls.Add(detailBar);
mainSplit.Size = new System.Drawing.Size(1024, 670);
mainSplit.SplitterDistance = 260;
mainSplit.SplitterWidth = 5;
mainSplit.TabIndex = 2;
//
// sidebarSplit
//
sidebarSplit.Dock = System.Windows.Forms.DockStyle.Left;
//
sidebarSplit.Dock = System.Windows.Forms.DockStyle.Fill;
sidebarSplit.Location = new System.Drawing.Point(0, 0);
sidebarSplit.Name = "sidebarSplit";
sidebarSplit.Orientation = System.Windows.Forms.Orientation.Horizontal;
//
//
// sidebarSplit.Panel1
//
//
sidebarSplit.Panel1.Controls.Add(fileList);
//
//
// sidebarSplit.Panel2
//
//
sidebarSplit.Panel2.Controls.Add(filterPanel);
sidebarSplit.Size = new System.Drawing.Size(260, 670);
sidebarSplit.SplitterDistance = 300;
sidebarSplit.SplitterWidth = 5;
sidebarSplit.TabIndex = 2;
sidebarSplit.TabIndex = 0;
//
// fileList
//
@@ -86,9 +115,9 @@ namespace OpenNest.Forms
entityView1.BackColor = System.Drawing.Color.FromArgb(33, 40, 48);
entityView1.Cursor = System.Windows.Forms.Cursors.Cross;
entityView1.Dock = System.Windows.Forms.DockStyle.Fill;
entityView1.Location = new System.Drawing.Point(260, 0);
entityView1.Location = new System.Drawing.Point(0, 0);
entityView1.Name = "entityView1";
entityView1.Size = new System.Drawing.Size(764, 634);
entityView1.Size = new System.Drawing.Size(759, 634);
entityView1.TabIndex = 0;
//
// detailBar
@@ -101,13 +130,16 @@ namespace OpenNest.Forms
detailBar.Controls.Add(lblDimensions);
detailBar.Controls.Add(lblEntityCount);
detailBar.Controls.Add(btnSplit);
detailBar.Controls.Add(btnSimplify);
detailBar.Controls.Add(btnExportDxf);
detailBar.Controls.Add(chkShowOriginal);
detailBar.Controls.Add(lblDetect);
detailBar.Controls.Add(cboBendDetector);
detailBar.Dock = System.Windows.Forms.DockStyle.Bottom;
detailBar.Location = new System.Drawing.Point(260, 634);
detailBar.Location = new System.Drawing.Point(0, 634);
detailBar.Name = "detailBar";
detailBar.Padding = new System.Windows.Forms.Padding(4, 6, 4, 4);
detailBar.Size = new System.Drawing.Size(764, 36);
detailBar.Size = new System.Drawing.Size(759, 36);
detailBar.TabIndex = 1;
detailBar.WrapContents = false;
//
@@ -187,9 +219,35 @@ namespace OpenNest.Forms
btnSplit.Size = new System.Drawing.Size(60, 24);
btnSplit.TabIndex = 6;
btnSplit.Text = "Split...";
//
//
// btnSimplify
//
btnSimplify.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
btnSimplify.Font = new System.Drawing.Font("Segoe UI", 9F);
btnSimplify.Text = "Simplify...";
btnSimplify.AutoSize = true;
btnSimplify.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
btnSimplify.Click += new System.EventHandler(this.OnSimplifyClick);
//
// btnExportDxf
//
btnExportDxf.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
btnExportDxf.Font = new System.Drawing.Font("Segoe UI", 9F);
btnExportDxf.Text = "Export DXF";
btnExportDxf.AutoSize = true;
btnExportDxf.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
btnExportDxf.Click += new System.EventHandler(this.OnExportDxfClick);
//
// chkShowOriginal
//
chkShowOriginal.AutoSize = true;
chkShowOriginal.Font = new System.Drawing.Font("Segoe UI", 9F);
chkShowOriginal.Text = "Original";
chkShowOriginal.Margin = new System.Windows.Forms.Padding(6, 3, 0, 0);
chkShowOriginal.CheckedChanged += new System.EventHandler(this.OnShowOriginalChanged);
//
// lblDetect
//
//
lblDetect.AutoSize = true;
lblDetect.Font = new System.Drawing.Font("Segoe UI", 9F);
lblDetect.Location = new System.Drawing.Point(361, 9);
@@ -248,9 +306,7 @@ namespace OpenNest.Forms
AllowDrop = true;
AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
ClientSize = new System.Drawing.Size(1024, 720);
Controls.Add(entityView1);
Controls.Add(detailBar);
Controls.Add(sidebarSplit);
Controls.Add(mainSplit);
Controls.Add(bottomPanel1);
Font = new System.Drawing.Font("Segoe UI", 9F);
MinimizeBox = false;
@@ -260,6 +316,10 @@ namespace OpenNest.Forms
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
Text = "CAD Converter";
WindowState = System.Windows.Forms.FormWindowState.Maximized;
mainSplit.Panel1.ResumeLayout(false);
mainSplit.Panel2.ResumeLayout(false);
((System.ComponentModel.ISupportInitialize)mainSplit).EndInit();
mainSplit.ResumeLayout(false);
sidebarSplit.Panel1.ResumeLayout(false);
sidebarSplit.Panel2.ResumeLayout(false);
((System.ComponentModel.ISupportInitialize)sidebarSplit).EndInit();
@@ -273,6 +333,7 @@ namespace OpenNest.Forms
#endregion
private System.Windows.Forms.SplitContainer mainSplit;
private System.Windows.Forms.SplitContainer sidebarSplit;
private Controls.FileListControl fileList;
private Controls.FilterPanel filterPanel;
@@ -283,6 +344,9 @@ namespace OpenNest.Forms
private System.Windows.Forms.NumericUpDown numQuantity;
private System.Windows.Forms.TextBox txtCustomer;
private System.Windows.Forms.Button btnSplit;
private System.Windows.Forms.Button btnSimplify;
private System.Windows.Forms.Button btnExportDxf;
private System.Windows.Forms.CheckBox chkShowOriginal;
private System.Windows.Forms.ComboBox cboBendDetector;
private System.Windows.Forms.Label lblQty;
private System.Windows.Forms.Label lblCust;
+157
View File
@@ -20,6 +20,7 @@ namespace OpenNest.Forms
public partial class CadConverterForm : Form
{
private static int colorIndex;
private SimplifierViewerForm simplifierViewer;
public CadConverterForm()
{
@@ -140,6 +141,7 @@ namespace OpenNest.Forms
entityView1.IsPickingBendLine = false;
filterPanel.SetPickMode(false);
}
entityView1.OriginalEntities = chkShowOriginal.Checked ? item.OriginalEntities : null;
entityView1.Entities.Clear();
entityView1.Entities.AddRange(item.Entities);
entityView1.Bends = item.Bends ?? new List<Bend>();
@@ -161,6 +163,50 @@ namespace OpenNest.Forms
lblEntityCount.Text = $"{item.EntityCount} entities";
entityView1.ZoomToFit();
CheckSimplifiable(item);
}
private void CheckSimplifiable(FileListItem item)
{
ResetSimplifyButton();
// Only check original (unsimplified) entities
var entities = item.OriginalEntities ?? item.Entities;
if (entities == null || entities.Count < 10) return;
// Quick line count check — need at least MinLines consecutive lines
var lineCount = entities.Count(e => e is Geometry.Line);
if (lineCount < 3) return;
// Run a quick analysis on a background thread
var capturedEntities = new List<Entity>(entities);
Task.Run(() =>
{
var shapes = ShapeBuilder.GetShapes(capturedEntities);
var simplifier = new GeometrySimplifier();
var count = 0;
foreach (var shape in shapes)
count += simplifier.Analyze(shape).Count;
return count;
}).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully && t.Result > 0)
HighlightSimplifyButton(t.Result);
}, TaskScheduler.FromCurrentSynchronizationContext());
}
private void HighlightSimplifyButton(int candidateCount)
{
btnSimplify.Text = $"Simplify ({candidateCount})";
btnSimplify.BackColor = Color.FromArgb(60, 120, 60);
btnSimplify.ForeColor = Color.White;
}
private void ResetSimplifyButton()
{
btnSimplify.Text = "Simplify...";
btnSimplify.BackColor = SystemColors.Control;
btnSimplify.ForeColor = SystemColors.ControlText;
}
private void ClearDetailBar()
@@ -378,6 +424,117 @@ namespace OpenNest.Forms
}
}
private void OnSimplifyClick(object sender, EventArgs e)
{
if (entityView1.Entities == null || entityView1.Entities.Count == 0)
return;
// Always simplify from original geometry to prevent tolerance creep
var item = CurrentItem;
if (item != null && item.OriginalEntities == null)
item.OriginalEntities = new List<Entity>(item.Entities);
var sourceEntities = item?.OriginalEntities ?? entityView1.Entities;
var shapes = ShapeBuilder.GetShapes(sourceEntities);
if (shapes.Count == 0)
return;
if (simplifierViewer == null || simplifierViewer.IsDisposed)
{
simplifierViewer = new SimplifierViewerForm();
simplifierViewer.Owner = this;
simplifierViewer.Applied += OnSimplifierApplied;
// Position next to this form
var screen = Screen.FromControl(this);
simplifierViewer.Location = new Point(
System.Math.Min(Right, screen.WorkingArea.Right - simplifierViewer.Width),
Top);
}
simplifierViewer.LoadShapes(shapes, entityView1);
}
private void OnSimplifierApplied(List<Entity> entities)
{
entityView1.Entities.Clear();
entityView1.Entities.AddRange(entities);
entityView1.ZoomToFit();
entityView1.Invalidate();
var item = CurrentItem;
if (item != null)
{
item.Entities = entities;
item.EntityCount = entities.Count;
item.Bounds = entities.GetBoundingBox();
}
lblEntityCount.Text = $"{entities.Count} entities";
ResetSimplifyButton();
}
private void OnShowOriginalChanged(object sender, EventArgs e)
{
var item = CurrentItem;
entityView1.OriginalEntities = chkShowOriginal.Checked ? item?.OriginalEntities : null;
entityView1.Invalidate();
}
private void OnExportDxfClick(object sender, EventArgs e)
{
var item = CurrentItem;
if (item == null) return;
using var dlg = new SaveFileDialog
{
Filter = "DXF Files|*.dxf",
FileName = Path.ChangeExtension(item.Name, ".dxf"),
};
if (dlg.ShowDialog() != DialogResult.OK) return;
var doc = new ACadSharp.CadDocument();
foreach (var entity in item.Entities)
{
switch (entity)
{
case Geometry.Line line:
doc.Entities.Add(new ACadSharp.Entities.Line
{
StartPoint = new CSMath.XYZ(line.StartPoint.X, line.StartPoint.Y, 0),
EndPoint = new CSMath.XYZ(line.EndPoint.X, line.EndPoint.Y, 0),
});
break;
case Geometry.Arc arc:
var startAngle = arc.StartAngle;
var endAngle = arc.EndAngle;
if (arc.IsReversed)
OpenNest.Math.Generic.Swap(ref startAngle, ref endAngle);
doc.Entities.Add(new ACadSharp.Entities.Arc
{
Center = new CSMath.XYZ(arc.Center.X, arc.Center.Y, 0),
Radius = arc.Radius,
StartAngle = startAngle,
EndAngle = endAngle,
});
break;
case Geometry.Circle circle:
doc.Entities.Add(new ACadSharp.Entities.Circle
{
Center = new CSMath.XYZ(circle.Center.X, circle.Center.Y, 0),
Radius = circle.Radius,
});
break;
}
}
using var writer = new ACadSharp.IO.DxfWriter(dlg.FileName, doc, false);
writer.Write();
}
#endregion
#region Output
+6 -4
View File
@@ -58,6 +58,8 @@ namespace OpenNest.Forms
var enginesDir = Path.Combine(Application.StartupPath, "Engines");
NestEngineRegistry.LoadPlugins(enginesDir);
OptionsForm.ApplyDisabledStrategies();
foreach (var engine in NestEngineRegistry.AvailableEngines)
engineComboBox.Items.Add(engine.Name);
@@ -79,7 +81,7 @@ namespace OpenNest.Forms
private string GetNestName(DateTime date, int id)
{
var year = (date.Year % 100).ToString("D2");
var seq = ToBase36(id).PadLeft(3, '0');
var seq = ToBase36(id).PadLeft(3, '2');
return $"N{year}-{seq}";
}
@@ -87,13 +89,13 @@ namespace OpenNest.Forms
private static string ToBase36(int value)
{
const string chars = "2345679ACDEFGHJKLMNPQRSTUVWXYZ";
if (value == 0) return "0";
if (value == 0) return chars[0].ToString();
var result = "";
while (value > 0)
{
result = chars[value % 36] + result;
value /= 36;
result = chars[value % chars.Length] + result;
value /= chars.Length;
}
return result;
}
+14 -2
View File
@@ -42,6 +42,7 @@
this.saveButton = new System.Windows.Forms.Button();
this.cancelButton = new System.Windows.Forms.Button();
this.bottomPanel1 = new OpenNest.Controls.BottomPanel();
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
this.tableLayoutPanel1.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.numericUpDown2)).BeginInit();
@@ -210,13 +211,23 @@
this.bottomPanel1.Name = "bottomPanel1";
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
this.bottomPanel1.TabIndex = 1;
//
//
// strategyGroupBox
//
this.strategyGroupBox.Location = new System.Drawing.Point(12, 178);
this.strategyGroupBox.Name = "strategyGroupBox";
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
this.strategyGroupBox.TabIndex = 2;
this.strategyGroupBox.TabStop = false;
this.strategyGroupBox.Text = "Fill Strategies";
//
// OptionsForm
//
//
this.AcceptButton = this.saveButton;
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
this.CancelButton = this.cancelButton;
this.ClientSize = new System.Drawing.Size(708, 418);
this.Controls.Add(this.strategyGroupBox);
this.Controls.Add(this.tableLayoutPanel1);
this.Controls.Add(this.bottomPanel1);
this.Font = new System.Drawing.Font("Microsoft Sans Serif", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
@@ -252,5 +263,6 @@
private System.Windows.Forms.TextBox textBox1;
private System.Windows.Forms.Label label3;
private System.Windows.Forms.Button button1;
private System.Windows.Forms.GroupBox strategyGroupBox;
}
}
+67 -4
View File
@@ -1,27 +1,58 @@
using OpenNest.Properties;
using OpenNest.Engine.Strategies;
using OpenNest.Properties;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;
namespace OpenNest.Forms
{
public partial class OptionsForm : Form
{
private readonly List<CheckBox> _strategyCheckBoxes = new();
public OptionsForm()
{
InitializeComponent();
BuildStrategyCheckBoxes();
}
protected override void OnLoad(System.EventArgs e)
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
LoadSettings();
}
private void BuildStrategyCheckBoxes()
{
var strategies = FillStrategyRegistry.AllStrategies;
var y = 20;
foreach (var strategy in strategies)
{
var cb = new CheckBox
{
Text = strategy.Name,
Tag = strategy.Name,
AutoSize = true,
Location = new System.Drawing.Point(10, y),
};
strategyGroupBox.Controls.Add(cb);
_strategyCheckBoxes.Add(cb);
y += 24;
}
}
private void LoadSettings()
{
textBox1.Text = Settings.Default.NestTemplatePath;
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
numericUpDown2.Value = (decimal)Settings.Default.ImportSplinePrecision;
var disabledNames = ParseDisabledStrategies(Settings.Default.DisabledStrategies);
foreach (var cb in _strategyCheckBoxes)
cb.Checked = !disabledNames.Contains((string)cb.Tag);
}
private void SaveSettings()
@@ -30,15 +61,47 @@ namespace OpenNest.Forms
Settings.Default.CreateNewNestOnOpen = checkBox1.Checked;
Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value;
Settings.Default.ImportSplinePrecision = (int)numericUpDown2.Value;
var disabledNames = _strategyCheckBoxes
.Where(cb => !cb.Checked)
.Select(cb => (string)cb.Tag);
Settings.Default.DisabledStrategies = string.Join(",", disabledNames);
Settings.Default.Save();
ApplyDisabledStrategies();
}
private void SaveSettings_Click(object sender, System.EventArgs e)
/// <summary>
/// Applies the DisabledStrategies setting to the FillStrategyRegistry.
/// Called on save and at startup from MainForm.
/// </summary>
public static void ApplyDisabledStrategies()
{
// Re-enable all, then disable the persisted set.
var all = FillStrategyRegistry.AllStrategies.Select(s => s.Name).ToArray();
FillStrategyRegistry.Enable(all);
var disabled = ParseDisabledStrategies(Settings.Default.DisabledStrategies);
if (disabled.Count > 0)
FillStrategyRegistry.Disable(disabled.ToArray());
}
private static HashSet<string> ParseDisabledStrategies(string value)
{
if (string.IsNullOrWhiteSpace(value))
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
return new HashSet<string>(
value.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0),
StringComparer.OrdinalIgnoreCase);
}
private void SaveSettings_Click(object sender, EventArgs e)
{
SaveSettings();
}
private void BrowseNestTemplatePath_Click(object sender, System.EventArgs e)
private void BrowseNestTemplatePath_Click(object sender, EventArgs e)
{
var dlg = new OpenFileDialog();
dlg.Filter = "Template File|*.nstdot";
+193
View File
@@ -0,0 +1,193 @@
namespace OpenNest.Forms
{
partial class SimplifierViewerForm
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.listView = new System.Windows.Forms.ListView();
this.columnLines = new System.Windows.Forms.ColumnHeader();
this.columnRadius = new System.Windows.Forms.ColumnHeader();
this.columnDeviation = new System.Windows.Forms.ColumnHeader();
this.columnLocation = new System.Windows.Forms.ColumnHeader();
this.bottomPanel = new System.Windows.Forms.FlowLayoutPanel();
this.lblTolerance = new System.Windows.Forms.Label();
this.numTolerance = new System.Windows.Forms.NumericUpDown();
this.lblCount = new System.Windows.Forms.Label();
this.btnApply = new System.Windows.Forms.Button();
this.bottomPanel.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.numTolerance)).BeginInit();
this.SuspendLayout();
//
// listView
//
this.listView.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] {
this.columnLines,
this.columnRadius,
this.columnDeviation,
this.columnLocation});
this.listView.CheckBoxes = true;
this.listView.Dock = System.Windows.Forms.DockStyle.Fill;
this.listView.FullRowSelect = true;
this.listView.GridLines = true;
this.listView.Location = new System.Drawing.Point(0, 0);
this.listView.Name = "listView";
this.listView.Size = new System.Drawing.Size(404, 378);
this.listView.TabIndex = 0;
this.listView.UseCompatibleStateImageBehavior = false;
this.listView.View = System.Windows.Forms.View.Details;
this.listView.ItemChecked += new System.Windows.Forms.ItemCheckedEventHandler(this.OnItemChecked);
this.listView.ItemSelectionChanged += new System.Windows.Forms.ListViewItemSelectionChangedEventHandler(this.OnItemSelected);
//
// columnLines
//
this.columnLines.Text = "Lines";
this.columnLines.Width = 50;
//
// columnRadius
//
this.columnRadius.Text = "Radius";
this.columnRadius.Width = 70;
//
// columnDeviation
//
this.columnDeviation.Text = "Deviation";
this.columnDeviation.Width = 75;
//
// columnLocation
//
this.columnLocation.Text = "Location";
this.columnLocation.Width = 100;
//
// bottomPanel
//
this.bottomPanel.Controls.Add(this.lblTolerance);
this.bottomPanel.Controls.Add(this.numTolerance);
this.bottomPanel.Controls.Add(this.lblCount);
this.bottomPanel.Controls.Add(this.btnApply);
this.bottomPanel.Dock = System.Windows.Forms.DockStyle.Bottom;
this.bottomPanel.Location = new System.Drawing.Point(0, 378);
this.bottomPanel.Name = "bottomPanel";
this.bottomPanel.Padding = new System.Windows.Forms.Padding(4, 6, 4, 4);
this.bottomPanel.Size = new System.Drawing.Size(404, 36);
this.bottomPanel.TabIndex = 1;
this.bottomPanel.WrapContents = false;
//
// lblTolerance
//
this.lblTolerance.AutoSize = true;
this.lblTolerance.Location = new System.Drawing.Point(7, 9);
this.lblTolerance.Margin = new System.Windows.Forms.Padding(0, 3, 2, 0);
this.lblTolerance.Name = "lblTolerance";
this.lblTolerance.Size = new System.Drawing.Size(61, 15);
this.lblTolerance.TabIndex = 0;
this.lblTolerance.Text = "Tolerance:";
//
// numTolerance
//
this.numTolerance.DecimalPlaces = 3;
this.numTolerance.Increment = new decimal(new int[] {
5,
0,
0,
196608});
this.numTolerance.Location = new System.Drawing.Point(73, 6);
this.numTolerance.Maximum = new decimal(new int[] {
5,
0,
0,
0});
this.numTolerance.Minimum = new decimal(new int[] {
1,
0,
0,
196608});
this.numTolerance.Name = "numTolerance";
this.numTolerance.Size = new System.Drawing.Size(70, 23);
this.numTolerance.TabIndex = 1;
this.numTolerance.Value = new decimal(new int[] {
20,
0,
0,
196608});
this.numTolerance.ValueChanged += new System.EventHandler(this.OnToleranceChanged);
//
// lblCount
//
this.lblCount.AutoSize = true;
this.lblCount.Location = new System.Drawing.Point(155, 9);
this.lblCount.Margin = new System.Windows.Forms.Padding(8, 3, 4, 0);
this.lblCount.Name = "lblCount";
this.lblCount.Size = new System.Drawing.Size(84, 15);
this.lblCount.TabIndex = 2;
this.lblCount.Text = "0 of 0 selected";
//
// btnApply
//
this.btnApply.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnApply.Location = new System.Drawing.Point(247, 6);
this.btnApply.Margin = new System.Windows.Forms.Padding(4, 0, 0, 0);
this.btnApply.Name = "btnApply";
this.btnApply.Size = new System.Drawing.Size(60, 25);
this.btnApply.TabIndex = 3;
this.btnApply.Text = "Apply";
this.btnApply.UseVisualStyleBackColor = true;
this.btnApply.Click += new System.EventHandler(this.OnApplyClick);
//
// SimplifierViewerForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(404, 414);
this.Controls.Add(this.listView);
this.Controls.Add(this.bottomPanel);
this.Font = new System.Drawing.Font("Segoe UI", 9F);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.SizableToolWindow;
this.Name = "SimplifierViewerForm";
this.ShowInTaskbar = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.Manual;
this.Text = "Geometry Simplifier";
this.TopMost = true;
this.bottomPanel.ResumeLayout(false);
this.bottomPanel.PerformLayout();
((System.ComponentModel.ISupportInitialize)(this.numTolerance)).EndInit();
this.ResumeLayout(false);
}
#endregion
private System.Windows.Forms.ListView listView;
private System.Windows.Forms.ColumnHeader columnLines;
private System.Windows.Forms.ColumnHeader columnRadius;
private System.Windows.Forms.ColumnHeader columnDeviation;
private System.Windows.Forms.ColumnHeader columnLocation;
private System.Windows.Forms.FlowLayoutPanel bottomPanel;
private System.Windows.Forms.Label lblTolerance;
private System.Windows.Forms.NumericUpDown numTolerance;
private System.Windows.Forms.Label lblCount;
private System.Windows.Forms.Button btnApply;
}
}
+171
View File
@@ -0,0 +1,171 @@
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.Geometry;
namespace OpenNest.Forms;
public partial class SimplifierViewerForm : Form
{
private EntityView entityView;
private GeometrySimplifier simplifier;
private List<Shape> shapes;
private List<ArcCandidate> candidates;
public event System.Action<List<Entity>> Applied;
public SimplifierViewerForm()
{
InitializeComponent();
}
public void LoadShapes(List<Shape> shapes, EntityView view, double tolerance = 0.004)
{
this.shapes = shapes;
this.entityView = view;
numTolerance.Value = (decimal)tolerance;
simplifier = new GeometrySimplifier { Tolerance = tolerance };
RunAnalysis();
Show();
BringToFront();
}
private void RunAnalysis()
{
candidates = new List<ArcCandidate>();
for (var i = 0; i < shapes.Count; i++)
{
var shapeCandidates = simplifier.Analyze(shapes[i]);
foreach (var c in shapeCandidates)
c.ShapeIndex = i;
var axis = GeometrySimplifier.DetectMirrorAxis(shapes[i]);
if (axis.IsValid)
simplifier.Symmetrize(shapeCandidates, axis);
candidates.AddRange(shapeCandidates);
}
RefreshList();
}
private void RefreshList()
{
listView.BeginUpdate();
listView.Items.Clear();
foreach (var c in candidates)
{
var item = new ListViewItem(c.LineCount.ToString());
item.Checked = c.IsSelected;
item.SubItems.Add(c.FittedArc.Radius.ToString("F3"));
item.SubItems.Add(c.MaxDeviation.ToString("F4"));
item.SubItems.Add($"{c.BoundingBox.Center.X:F1}, {c.BoundingBox.Center.Y:F1}");
item.Tag = c;
listView.Items.Add(item);
}
listView.EndUpdate();
UpdateCountLabel();
}
private void UpdateCountLabel()
{
var selected = candidates.Count(c => c.IsSelected);
lblCount.Text = $"{selected} of {candidates.Count} selected";
btnApply.Enabled = selected > 0;
}
private void OnItemSelected(object sender, ListViewItemSelectionChangedEventArgs e)
{
if (!e.IsSelected || e.Item.Tag is not ArcCandidate candidate)
{
entityView?.ClearSimplifierPreview();
return;
}
// Highlight the candidate lines in the shape
var shape = shapes[candidate.ShapeIndex];
var highlightEntities = new List<Entity>();
for (var i = candidate.StartIndex; i <= candidate.EndIndex; i++)
highlightEntities.Add(shape.Entities[i]);
entityView.SimplifierHighlight = highlightEntities;
entityView.SimplifierPreview = candidate.FittedArc;
// Build tolerance zone by offsetting each original line both directions
var tol = simplifier.Tolerance;
var leftEntities = new List<Entity>();
var rightEntities = new List<Entity>();
foreach (var entity in highlightEntities)
{
var left = entity.OffsetEntity(tol, OffsetSide.Left);
var right = entity.OffsetEntity(tol, OffsetSide.Right);
if (left != null) leftEntities.Add(left);
if (right != null) rightEntities.Add(right);
}
entityView.SimplifierToleranceLeft = leftEntities;
entityView.SimplifierToleranceRight = rightEntities;
// Zoom with padding for the tolerance zone
var padded = new Box(
candidate.BoundingBox.X - tol * 2,
candidate.BoundingBox.Y - tol * 2,
candidate.BoundingBox.Width + tol * 4,
candidate.BoundingBox.Length + tol * 4);
entityView.ZoomToArea(padded);
}
private void OnItemChecked(object sender, ItemCheckedEventArgs e)
{
if (e.Item.Tag is ArcCandidate candidate)
{
candidate.IsSelected = e.Item.Checked;
UpdateCountLabel();
}
}
private void OnToleranceChanged(object sender, System.EventArgs e)
{
if (simplifier == null) return;
simplifier.Tolerance = (double)numTolerance.Value;
entityView?.ClearSimplifierPreview();
RunAnalysis();
}
private void OnApplyClick(object sender, System.EventArgs e)
{
var byShape = candidates
.Where(c => c.IsSelected)
.GroupBy(c => c.ShapeIndex)
.ToDictionary(g => g.Key, g => g.ToList());
for (var i = 0; i < shapes.Count; i++)
{
if (byShape.TryGetValue(i, out var selected))
shapes[i] = simplifier.Apply(shapes[i], selected);
}
var entities = shapes.SelectMany(s => s.Entities).ToList();
entityView?.ClearSimplifierPreview();
Applied?.Invoke(entities);
Close();
}
protected override bool ProcessDialogKey(Keys keyData)
{
if (keyData == Keys.Escape)
{
Close();
return true;
}
return base.ProcessDialogKey(keyData);
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
entityView?.ClearSimplifierPreview();
base.OnFormClosing(e);
}
}
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+12
View File
@@ -214,5 +214,17 @@ namespace OpenNest.Properties {
this["LastPierceTime"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("")]
public string DisabledStrategies {
get {
return ((string)(this["DisabledStrategies"]));
}
set {
this["DisabledStrategies"] = value;
}
}
}
}
+3
View File
@@ -50,5 +50,8 @@
<Setting Name="LastPierceTime" Type="System.Decimal" Scope="User">
<Value Profile="(Default)">0</Value>
</Setting>
<Setting Name="DisabledStrategies" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
</Settings>
</SettingsFile>
+99 -36
View File
@@ -1,6 +1,6 @@
# OpenNest
A Windows desktop app for CNC nesting — imports DXF drawings, arranges parts on plates and exports layouts as DXF or G-code for cutting.
A Windows desktop application for CNC nesting — imports DXF drawings, arranges parts on material plates, and exports layouts as DXF or G-code for cutting.
![OpenNest - parts nested on a 36x36 plate](screenshots/screenshot-nest-1.png)
@@ -8,15 +8,21 @@ OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and
## Features
- **DXF Import/Export** — Load part drawings from DXF files and export completed nest layouts
- **Multiple Fill Strategies** — Grid-based linear fill and rectangle bin packing
- **Part Rotation** — Automatically tries different rotation angles to find better fits
- **Gravity Compaction** — After placing parts, pushes them together to close gaps
- **DXF/DWG Import & Export** — Load part drawings from DXF or DWG files and export completed nest layouts as DXF
- **Multiple Fill Strategies** — Grid-based linear fill, interlocking pair fill, rectangle bin packing, extents-based tiling, and more via a pluggable strategy system
- **Best-Fit Pair Nesting** — NFP-based (No Fit Polygon) pair evaluation finds tight-fitting interlocking orientations between parts
- **GPU Acceleration** — Optional ILGPU-based bitmap overlap detection for faster best-fit evaluation
- **Part Rotation** — Automatically tries different rotation angles to find better fits, with optional ML-based angle prediction (ONNX)
- **Gravity Compaction** — After placing parts, pushes them together using polygon-based directional distance to close gaps between irregular shapes
- **Multi-Plate Support** — Work with multiple plates of different sizes and materials in a single nest
- **G-code Output** — Post-process nested layouts to G-code for CNC cutting machines
- **Built-in Shapes** — Create basic geometric parts (circles, rectangles, triangles, etc.) without needing a DXF file
- **Interactive Editing** — Zoom, pan, select, clone, and manually arrange parts on the plate view
- **Lead-in/Lead-out & Tabs** — Cutting parameters like approach paths and holding tabs (engine support, UI coming soon)
- **Sheet Cut-Offs** — Automatically cut the plate to size after nesting, with geometry-aware clearance that avoids placed parts
- **Drawing Splitting** — Split oversized parts into pieces that fit your plate, with straight cuts, weld-gap tabs, or interlocking spike-groove joints
- **Bend Line Detection** — Import bend lines from DXF files with pluggable detectors (SolidWorks flat pattern support built in)
- **Lead-In/Lead-Out & Tabs** — Configurable approach paths, exit paths, and holding tabs for CNC cutting
- **G-code Output** — Post-process nested layouts to G-code via plugin post-processors
- **Built-in Shapes** — 12 parametric shapes (circles, rectangles, L-shapes, T-shapes, flanges, etc.) for quick testing or simple parts
- **Interactive Editing** — Zoom, pan, select, clone, push, and manually arrange parts on the plate view
- **Pluggable Engine Architecture** — Swap between built-in nesting engines or load custom engines from plugin DLLs
![OpenNest - 44 parts nested on a 60x120 plate](screenshots/screenshot-nest-2.png)
@@ -46,12 +52,11 @@ Or open `OpenNest.sln` in Visual Studio and run the `OpenNest` project.
### Quick Walkthrough
1. **Create a nest** — File > New Nest
2. **Add drawings** — Import DXF files or create built-in shapes (rectangles, circles, etc.). DXF drawings should be 1:1 scale CAD files.
3. **Set up a plate** — Define the plate size and material
4. **Fill the plate** — The nesting engine will automatically arrange parts on the plate
5. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
<!-- TODO: Add screenshots for each step -->
2. **Add drawings** — Import DXF files via the CAD Converter (handles bend detection, layer filtering, and color/linetype exclusion) or create built-in shapes
3. **Set up a plate** — Define the plate size, material, quadrant, and spacing
4. **Fill the plate** — The nesting engine arranges parts automatically using the active fill strategy
5. **Add cut-offs** — Optionally add horizontal/vertical cut-off lines to trim unused plate material
6. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
## Command-Line Interface
@@ -95,6 +100,7 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
| `--keep-parts` | Keep existing parts instead of clearing before fill |
| `--check-overlaps` | Run overlap detection after fill (exits with code 1 if found) |
| `--engine <name>` | Select a registered nesting engine |
| `--post <name>` | Post-process the result with the named post-processor plugin |
| `--no-save` | Skip saving the output file |
| `--no-log` | Skip writing the debug log |
@@ -102,26 +108,74 @@ dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- project.zip ext
```
OpenNest.sln
├── OpenNest/ # WinForms desktop application (UI)
├── OpenNest.Core/ # Domain model, geometry, and CNC primitives
├── OpenNest.Engine/ # Nesting algorithms (fill, pack, compact)
├── OpenNest.IO/ # File I/O — DXF import/export, nest file format
├── OpenNest.Console/ # Command-line interface for batch nesting
├── OpenNest.Gpu/ # GPU-accelerated nesting evaluation
├── OpenNest.Training/ # ML training data collection
├── OpenNest.Mcp/ # MCP server for AI tool integration
── OpenNest.Tests/ # Unit tests
├── OpenNest/ # WinForms desktop application (UI)
├── OpenNest.Core/ # Domain model, geometry, and CNC primitives
├── OpenNest.Engine/ # Nesting algorithms (fill, pack, compact, best-fit)
├── OpenNest.IO/ # File I/O — DXF import/export, nest file format
├── OpenNest.Console/ # Command-line interface for batch nesting
├── OpenNest.Api/ # Programmatic nesting API (NestRunner pipeline)
├── OpenNest.Gpu/ # GPU-accelerated pair evaluation (ILGPU)
├── OpenNest.Training/ # ML training data collection (SQLite + EF Core)
── OpenNest.Mcp/ # MCP server for AI tool integration
├── OpenNest.Posts.Cincinnati/ # Cincinnati CL-707 laser post-processor plugin
└── OpenNest.Tests/ # Unit tests (xUnit)
```
For most users, only these matter:
| Project | What it does |
|---------|-------------|
| **OpenNest** | The app you run. WinForms UI with plate viewer, drawing list, and dialogs. |
| **OpenNest** | The app you run. WinForms MDI interface with plate viewer, drawing list, CAD converter, and dialogs. |
| **OpenNest.Console** | Command-line interface for batch nesting, scripting, and automation. |
| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation. |
| **OpenNest.Engine** | The brains — algorithms that decide where parts go on a plate. |
| **OpenNest.IO** | Reads and writes files — DXF (via ACadSharp), G-code, and the `.nest` ZIP format. |
| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation, bend lines, cut-offs, and drawing splitting. |
| **OpenNest.Engine** | The brains — fill strategies (linear, pairs, rect best-fit, extents), NFP-based pair evaluation, gravity compaction, and a pluggable engine registry. |
| **OpenNest.IO** | Reads and writes files — DXF/DWG (via ACadSharp), G-code, and the `.nest` ZIP format. |
| **OpenNest.Api** | High-level API for running the full nesting pipeline programmatically (import, nest, export). |
| **OpenNest.Gpu** | GPU-accelerated bitmap overlap detection for best-fit pair evaluation using ILGPU. |
| **OpenNest.Posts.Cincinnati** | Post-processor plugin for Cincinnati CL-707/800/900/940/CLX laser cutting machines. Outputs Cincinnati-format G-code with material library, kerf compensation, and pierce logic. |
| **OpenNest.Mcp** | MCP (Model Context Protocol) server exposing nesting operations as tools for AI assistants. |
| **OpenNest.Tests** | 75+ test files covering core geometry, fill strategies, splitting, bending, post-processing, and the API. |
## Nesting Engines
OpenNest uses a pluggable engine architecture. The active engine can be selected at runtime.
| Engine | Description |
|--------|-------------|
| **Default** | Multi-phase strategy: linear fill, pair fill, rect best-fit, then remainder. Balances density and speed. |
| **Vertical Remnant** | Optimizes for a clean vertical drop on the right side of the plate. |
| **Horizontal Remnant** | Optimizes for a clean horizontal drop on the top of the plate. |
Custom engines can be built by subclassing `NestEngineBase` and registering via `NestEngineRegistry` or dropping a plugin DLL in the `Engines/` directory.
### Fill Strategies
Each engine composes from a set of fill strategies:
| Strategy | Description |
|----------|-------------|
| **Linear** | Grid-based fill with geometry-aware copy distance and 4-config rotation/axis optimization |
| **Pairs** | NFP-based interlocking pair evaluation — finds tight-fitting orientations between two parts |
| **Rect Best-Fit** | Greedy rectangle bin-packing with horizontal and vertical orientation trials |
| **Extents** | Extents-based pair tiling for simple rectangular arrangements |
## Drawing Splitting
Oversized parts that don't fit on a single plate can be split into smaller pieces:
- **Straight Split** — Clean cut with no joining features
- **Weld-Gap Tabs** — Rectangular tab spacers on one side for weld alignment
- **Spike-Groove** — Interlocking V-shaped spike and groove pairs for self-aligning joints
The split system supports fit-to-plate (auto-calculates split lines) and split-by-count modes, with an interactive UI for adjusting split positions and feature parameters.
## Post-Processors
Post-processors convert nested layouts into machine-specific G-code. They are loaded as plugin DLLs from the `Posts/` directory at runtime.
**Included:**
- **Cincinnati** — Full post-processor for Cincinnati CL-707/800/900/940/CLX laser cutting machines with variable declarations, material library resolution, speed classification, kerf compensation, and optional part sub-programs (M98).
Custom post-processors implement the `IPostProcessor` interface and are auto-discovered from DLLs in the `Posts/` directory.
## Keyboard Shortcuts
@@ -131,6 +185,7 @@ For most users, only these matter:
| `F` | Zoom to fit the plate view |
| `Shift` + Mouse Wheel | Rotate parts when a drawing is selected |
| `Shift` + Left Click | Push the selected group of parts to the bottom-left most point |
| Middle Mouse Click | Rotate selected parts 90 degrees |
| `X` | Push selected parts left (negative X) |
| `Shift+X` | Push selected parts right (positive X) |
| `Y` | Push selected parts down (negative Y) |
@@ -145,18 +200,26 @@ For most users, only these matter:
| DXF (AutoCAD Drawing Exchange) | Yes | Yes |
| DWG (AutoCAD Drawing) | Yes | No |
| G-code | No | Yes (via post-processors) |
| `.nest` (ZIP-based project format) | Yes | Yes |
## Nest File Format
Nest files (`.nest`) are ZIP archives containing:
- `nest.json` — JSON metadata: nest info, plate defaults, drawings (with bend data), and plates (with parts and cut-offs)
- `programs/program-N` — G-code text for each drawing's cut program
- `bestfits/bestfit-N` — Cached best-fit pair evaluation results (optional)
## Roadmap
- **NFP-based nesting** — No Fit Polygon algorithms and simulated annealing optimizer exist in the engine but aren't integrated into the UI or engine registry yet
- **Lead-in/Lead-out UI** — Engine support for lead-ins, lead-outs, and tabs is implemented; needs a UI for configuration
- **Sheet cut-offs** — Cut the sheet to size after nesting to reduce waste
- **Post-processors** — Plugin interface (`IPostProcessor`) is in place; need to ship built-in post-processors for common CNC controllers
- **Shape library UI** — Built-in shape generation code exists; needs a browsable library UI for quick access
- **NFP-based auto-nesting** — Simulated annealing optimizer and NFP placement exist in the engine but aren't exposed as a selectable engine yet
- **Geometry simplifier** — Replace consecutive small line segments with fitted arcs to reduce program size and improve nesting performance
- **Shape library UI** — 12 built-in parametric shapes exist in code; needs a browsable library UI for quick access
- **Additional post-processors** — Plugin interface is in place; more machine-specific post-processors planned
## Status
OpenNest is under active development. The core nesting workflows function, but there's plenty of room for improvement in packing efficiency, UI polish, and format support. Contributions and feedback are welcome.
OpenNest is under active development. The core nesting workflows function end-to-end — from DXF import through filling, splitting, cut-offs, and G-code post-processing. Contributions and feedback are welcome.
## License
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,147 @@
# Direct Arc Conversion for Spline and Ellipse Import
## Problem
During DXF import, splines and ellipses are converted to many small line segments (200 for ellipses, control-point polygons for splines), then optionally reconstructed back to arcs via GeometrySimplifier in the CAD converter. This is wasteful and lossy:
- **Ellipses** are sampled into 200 line segments, discarding the known parametric form.
- **Splines** connect control points with lines, which is geometrically incorrect for B-splines (control points don't lie on the curve).
- Reconstructing arcs from approximate line segments is less accurate than fitting arcs to the exact curve.
## Solution
Convert splines and ellipses directly to circular arcs (and lines where necessary) during import, using the exact curve geometry. No user review step — the import produces the best representation automatically.
## Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| When to convert | During import (automatic) | User didn't ask for 200 lines; produce best representation |
| Tolerance | 0.001" default | Tighter than simplifier's 0.004" because we have exact curves |
| Ellipse method | Analytical (curvature-based) | We have the exact parametric form |
| Spline method | Sample-then-fit | ACadSharp provides `PolygonalVertexes()` for accurate curve points |
| Fallback | Keep line segments where arcs can't fit | Handles rapid curvature changes in splines |
| Junction continuity | G1 (tangent) continuity | Normal-constrained arc centers prevent serrated edges |
## Architecture
Two new classes in `OpenNest.Core/Geometry/`:
### EllipseConverter
**Input:** Ellipse parameters — center, semi-major axis length, semi-minor axis length, rotation angle, start parameter, end parameter, tolerance.
**Output:** `List<Entity>` containing arcs that approximate the ellipse within tolerance.
**Algorithm — normal-constrained arc fitting:**
1. Compute an initial set of split parameters along the ellipse. Start with quadrant boundaries (points of maximum/minimum curvature) as natural split candidates.
2. For each pair of consecutive split points (t_start, t_end):
a. Compute the ellipse normal at both endpoints analytically.
b. Find the arc center at the intersection of the two normals. This guarantees the arc is tangent to the ellipse at both endpoints (G1 continuity).
c. Compute the arc radius from the center to either endpoint.
d. Sample several points on the ellipse between t_start and t_end, and measure the maximum radial deviation from the fitted arc.
e. If deviation exceeds tolerance, subdivide: insert a split point at the midpoint and retry both halves.
f. If deviation is within tolerance, emit the arc.
3. Continue until all segments are within tolerance.
**Ellipse analytical formulas (in local coordinates before rotation):**
- Point: `P(t) = (a cos t, b sin t)`
- Tangent: `T(t) = (-a sin t, b cos t)`
- Normal (inward): perpendicular to tangent, pointing toward center of curvature
- Curvature: `k(t) = ab / (a^2 sin^2 t + b^2 cos^2 t)^(3/2)`
After computing in local coordinates, rotate by the ellipse's major axis angle and translate to center.
**Arc count:** Depends on eccentricity and tolerance. A nearly-circular ellipse needs 1-2 arcs. A highly eccentric one (ratio < 0.3) may need 8-16. Tolerance drives this automatically via subdivision.
**Closed ellipse handling:** When the ellipse sweep is approximately 2pi, ensure the last arc's endpoint connects back to the first arc's start point. Tangent continuity wraps around.
### SplineConverter
**Input:** List of points evaluated on the spline curve (from ACadSharp's `PolygonalVertexes`), tolerance, and whether the spline is closed.
**Output:** `List<Entity>` containing arcs and lines that approximate the spline within tolerance.
**Algorithm — tangent-chained greedy arc fitting:**
1. Evaluate the spline at high density using `PolygonalVertexes(precision)` where precision comes from the existing `SplinePrecision` setting.
2. Walk the evaluated points from the start:
a. At the current segment start, compute the tangent direction from the first two points (or from the chained tangent of the previous arc).
b. Fit an arc constrained to be tangent at the start point:
- The arc center lies on the normal to the tangent at the start point.
- Use the perpendicular bisector of the chord from start to candidate end point, intersected with the start normal, to find the center.
c. Extend the arc forward point-by-point. At each extension, recompute the center (intersection of start normal and chord bisector to the new endpoint) and check that all intermediate points are within tolerance of the arc.
d. When adding the next point would exceed tolerance, finalize the arc with the last good endpoint.
e. Compute the tangent at the arc's end point (perpendicular to the radius at that point) and chain it to the next segment.
3. If fewer than 3 points remain in a run where no arc fits (curvature changes too rapidly), emit line segments instead.
4. For closed splines, chain the final arc's tangent back to constrain the first arc.
**This is essentially the same approach GeometrySimplifier uses** (tangent chaining via `chainedTangent`), but operating on densely-sampled curve points rather than pre-existing line segments.
### Changes to Existing Code
#### Extensions.cs
```
// Before: returns List<Line>
public static List<Geometry.Line> ToOpenNest(this Spline spline)
// After: returns List<Entity>
public static List<Entity> ToOpenNest(this Spline spline, int precision)
```
- Extracts ACadSharp spline data, calls `SplineConverter.Convert()`
- Now accepts `precision` parameter (was ignored before)
```
// Before: returns List<Line>
public static List<Geometry.Line> ToOpenNest(this Ellipse ellipse, int precision = 200)
// After: returns List<Entity>
public static List<Entity> ToOpenNest(this Ellipse ellipse, double tolerance = 0.001)
```
- Extracts ACadSharp ellipse parameters, calls `EllipseConverter.Convert()`
- Precision parameter replaced by tolerance (precision is no longer relevant)
Both methods preserve Layer, Color, and LineTypeName on the output entities.
#### DxfImporter.cs
Currently collects `List<Line>` and `List<Arc>` separately. The new converters return `List<Entity>` (mixed arcs and lines). Options:
- Sort returned entities into the existing `lines` and `arcs` lists by type, OR
- Switch to a single `List<Entity>` collection
The simpler change is to sort into existing lists so downstream code (GeometryOptimizer, ShapeBuilder) is unaffected.
### What Stays the Same
- **GeometrySimplifier** — still exists for user-triggered simplification of genuinely line-based geometry (e.g., DXF files that actually contain line segments, or polylines)
- **GeometryOptimizer** — still merges collinear lines and coradial arcs post-import. May merge adjacent arcs produced by the new converters if they happen to be coradial.
- **ShapeBuilder, ConvertGeometry, CNC pipeline** — unchanged, they already handle mixed Line/Arc entities
- **SplinePrecision setting** — still used for spline point evaluation density
## Testing
- **EllipseConverter unit tests:**
- Circle (ratio = 1.0) produces 1-2 arcs
- Moderate ellipse produces arcs within tolerance
- Highly eccentric ellipse produces more arcs, all within tolerance
- Partial ellipse (elliptical arc) works correctly
- Endpoint continuity: each arc's end matches the next arc's start
- Tangent continuity: no discontinuities at junctions
- Closed ellipse: last arc connects back to first
- **SplineConverter unit tests:**
- Circular arc spline produces a single arc
- S-curve spline produces arcs + lines where needed
- Straight-line spline produces a line (not degenerate arcs)
- Closed spline: endpoints connect
- Tangent chaining: smooth transitions between consecutive arcs
- **Integration test:**
- Import a DXF with splines and ellipses, verify the result contains arcs (not 200 lines)
- Compare bounding boxes to ensure geometry is preserved