refactor: CutOff uses Dictionary<Part, Entity> instead of index-based list

Replace CutOff.BuildPerimeterCache (List<Shape>) with Plate.BuildPerimeterCache
(Dictionary<Part, Entity>) throughout. Consolidate two Regenerate overloads into
a single method with optional cache parameter. Fix Shape intersection bug where
non-intersecting entities added spurious Vector.Zero points to results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 22:42:32 -04:00
parent a735884ee9
commit 4287c5fa46
4 changed files with 133 additions and 80 deletions

View File

@@ -26,9 +26,9 @@ namespace OpenNest
Drawing = new Drawing(GetName()) { IsCutOff = true };
}
public void Regenerate(Plate plate, CutOffSettings settings)
public void Regenerate(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache = null)
{
var segments = ComputeSegments(plate, settings);
var segments = ComputeSegments(plate, settings, cache);
var program = BuildProgram(segments, settings);
Drawing.Program = program;
}
@@ -40,7 +40,7 @@ namespace OpenNest
return $"CutOff-{axisChar}-{coord:F2}";
}
private List<(double Start, double End)> ComputeSegments(Plate plate, CutOffSettings settings)
private List<(double Start, double End)> ComputeSegments(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache)
{
var bounds = plate.BoundingBox(includeParts: false);
@@ -66,26 +66,10 @@ namespace OpenNest
if (part.BaseDrawing.IsCutOff)
continue;
var bb = part.BoundingBox;
double partStart, partEnd, partMin, partMax;
if (Axis == CutOffAxis.Vertical)
{
partMin = bb.X - settings.PartClearance;
partMax = bb.X + bb.Width + settings.PartClearance;
partStart = bb.Y - settings.PartClearance;
partEnd = bb.Y + bb.Length + settings.PartClearance;
}
else
{
partMin = bb.Y - settings.PartClearance;
partMax = bb.Y + bb.Length + settings.PartClearance;
partStart = bb.X - settings.PartClearance;
partEnd = bb.X + bb.Width + settings.PartClearance;
}
if (cutPosition >= partMin && cutPosition <= partMax)
exclusions.Add((partStart, partEnd));
Entity perimeter = null;
cache?.TryGetValue(part, out perimeter);
var partExclusions = GetPartExclusions(part, perimeter, cutPosition, lineStart, lineEnd, settings.PartClearance);
exclusions.AddRange(partExclusions);
}
exclusions.Sort((a, b) => a.Start.CompareTo(b.Start));
@@ -120,6 +104,75 @@ namespace OpenNest
return segments;
}
private List<(double Start, double End)> GetPartExclusions(
Part part, Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
{
var bb = part.BoundingBox;
double partMin, partMax, partStart, partEnd;
if (Axis == CutOffAxis.Vertical)
{
partMin = bb.X - clearance;
partMax = bb.X + bb.Width + clearance;
partStart = bb.Y - clearance;
partEnd = bb.Y + bb.Length + clearance;
}
else
{
partMin = bb.Y - clearance;
partMax = bb.Y + bb.Length + clearance;
partStart = bb.X - clearance;
partEnd = bb.X + bb.Width + clearance;
}
if (cutPosition < partMin || cutPosition > partMax)
return new List<(double Start, double End)>();
if (perimeter != null)
{
var perimeterExclusions = IntersectPerimeter(perimeter, cutPosition, lineStart, lineEnd, clearance);
if (perimeterExclusions != null)
return perimeterExclusions;
}
return new List<(double Start, double End)> { (partStart, partEnd) };
}
private List<(double Start, double End)> IntersectPerimeter(
Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
{
Vector p1, p2;
if (Axis == CutOffAxis.Vertical)
{
p1 = new Vector(cutPosition, lineStart);
p2 = new Vector(cutPosition, lineEnd);
}
else
{
p1 = new Vector(lineStart, cutPosition);
p2 = new Vector(lineEnd, cutPosition);
}
var cutLine = new Line(p1, p2);
if (!perimeter.Intersects(cutLine, out var pts) || pts.Count < 2)
return null;
var coords = pts
.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
.OrderBy(c => c)
.ToList();
if (coords.Count % 2 != 0)
return null;
var result = new List<(double Start, double End)>();
for (var i = 0; i < coords.Count; i += 2)
result.Add((coords[i] - clearance, coords[i + 1] + clearance));
return result;
}
private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings)
{
var program = new Program();

View File

@@ -249,9 +249,8 @@ namespace OpenNest.Geometry
foreach (var geo in shape.Entities)
{
List<Vector> pts3;
geo.Intersects(line, out pts3);
pts.AddRange(pts3);
if (geo.Intersects(line, out var pts3))
pts.AddRange(pts3);
}
return pts.Count > 0;

View File

@@ -86,7 +86,7 @@ public class CutOffGeometryTests
part.Location = new Vector(0, 0);
plate.Parts.Add(part);
var cache = CutOff.BuildPerimeterCache(plate);
var cache = Plate.BuildPerimeterCache(plate);
// Cut at X=2: inside the BB but near the edge of the circle.
var cutoff = new CutOff(new Vector(2, 0), CutOffAxis.Vertical);
@@ -125,7 +125,7 @@ public class CutOffGeometryTests
part.Location = new Vector(0, 0);
plate.Parts.Add(part);
var cache = CutOff.BuildPerimeterCache(plate);
var cache = Plate.BuildPerimeterCache(plate);
var cutoff = new CutOff(new Vector(5, 0), CutOffAxis.Vertical);
cutoff.Regenerate(plate, ZeroClearance, cache);
@@ -157,7 +157,7 @@ public class CutOffGeometryTests
part.Location = new Vector(10, 10);
plate.Parts.Add(part);
var cache = CutOff.BuildPerimeterCache(plate);
var cache = Plate.BuildPerimeterCache(plate);
var cutoff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
cutoff.Regenerate(plate, ZeroClearance, cache);
@@ -202,7 +202,7 @@ public class CutOffGeometryTests
part.Location = new Vector(0, 0);
plate.Parts.Add(part);
var cache = CutOff.BuildPerimeterCache(plate);
var cache = Plate.BuildPerimeterCache(plate);
// Horizontal cut at Y=2: near the edge of the circle.
var cutoff = new CutOff(new Vector(0, 2), CutOffAxis.Horizontal);
@@ -233,7 +233,7 @@ public class CutOffGeometryTests
plate.Parts.Add(part);
var settings = new CutOffSettings { PartClearance = 5.0 };
var cache = CutOff.BuildPerimeterCache(plate);
var cache = Plate.BuildPerimeterCache(plate);
var cutoff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
cutoff.Regenerate(plate, settings, cache);
@@ -243,32 +243,8 @@ public class CutOffGeometryTests
}
[Fact]
public void BuildPerimeterCache_ReturnsOneEntryPerPart()
public void BuildPerimeterCache_OpenContourGetsConvexHull()
{
var plate = new Plate(100, 100);
plate.Parts.Add(new Part(new Drawing("a", MakeSquare(10))));
plate.Parts.Add(new Part(new Drawing("b", MakeCircle(5))));
plate.Parts.Add(new Part(new Drawing("c", MakeDiamond(8))));
var cache = CutOff.BuildPerimeterCache(plate);
Assert.Equal(3, cache.Count);
}
[Fact]
public void BuildPerimeterCache_SkipsCutOffParts()
{
var plate = new Plate(100, 100);
plate.Parts.Add(new Part(new Drawing("real", MakeSquare(10))));
plate.Parts.Add(new Part(new Drawing("cutoff", new Program()) { IsCutOff = true }));
var cache = CutOff.BuildPerimeterCache(plate);
Assert.Single(cache);
}
[Fact]
public void BuildPerimeterCache_NullForOpenContour()
{
// Open contour: line that doesn't close
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
@@ -277,9 +253,12 @@ public class CutOffGeometryTests
var plate = new Plate(100, 100);
plate.Parts.Add(new Part(new Drawing("open", pgm)));
var cache = CutOff.BuildPerimeterCache(plate);
var cache = Plate.BuildPerimeterCache(plate);
Assert.Single(cache);
Assert.Null(cache[0]);
var perimeter = cache[plate.Parts[0]];
Assert.NotNull(perimeter);
Assert.IsType<Polygon>(perimeter);
}
[Fact]

View File

@@ -1,6 +1,7 @@
using OpenNest.CNC;
using OpenNest.Controls;
using OpenNest.Geometry;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Drawing2D;
@@ -13,11 +14,18 @@ namespace OpenNest.Actions
{
private CutOff previewCutOff;
private CutOffSettings settings;
private CutOffAxis lockedAxis = CutOffAxis.Vertical;
private Dictionary<Part, Entity> perimeterCache;
private readonly Timer debounceTimer;
private bool regeneratePending;
public ActionCutOff(PlateView plateView)
: base(plateView)
{
settings = plateView.CutOffSettings;
perimeterCache = Plate.BuildPerimeterCache(plateView.Plate);
debounceTimer = new Timer { Interval = 16 };
debounceTimer.Tick += OnDebounce;
plateView.MouseMove += OnMouseMove;
plateView.MouseDown += OnMouseDown;
@@ -27,6 +35,8 @@ namespace OpenNest.Actions
public override void ConnectEvents()
{
perimeterCache = Plate.BuildPerimeterCache(plateView.Plate);
plateView.MouseMove += OnMouseMove;
plateView.MouseDown += OnMouseDown;
plateView.KeyDown += OnKeyDown;
@@ -35,12 +45,14 @@ namespace OpenNest.Actions
public override void DisconnectEvents()
{
debounceTimer.Stop();
plateView.MouseMove -= OnMouseMove;
plateView.MouseDown -= OnMouseDown;
plateView.KeyDown -= OnKeyDown;
plateView.Paint -= OnPaint;
previewCutOff = null;
perimeterCache = null;
plateView.Invalidate();
}
@@ -50,11 +62,21 @@ namespace OpenNest.Actions
private void OnMouseMove(object sender, MouseEventArgs e)
{
var pt = plateView.CurrentPoint;
var axis = DetectAxis(pt);
regeneratePending = true;
debounceTimer.Start();
}
previewCutOff = new CutOff(pt, axis);
previewCutOff.Regenerate(plateView.Plate, settings);
private void OnDebounce(object sender, System.EventArgs e)
{
debounceTimer.Stop();
if (!regeneratePending)
return;
regeneratePending = false;
var pt = plateView.CurrentPoint;
previewCutOff = new CutOff(pt, lockedAxis);
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
plateView.Invalidate();
}
@@ -64,18 +86,33 @@ namespace OpenNest.Actions
return;
var pt = plateView.CurrentPoint;
var axis = DetectAxis(pt);
var cutoff = new CutOff(pt, axis);
var cutoff = new CutOff(pt, lockedAxis);
plateView.Plate.CutOffs.Add(cutoff);
plateView.Plate.RegenerateCutOffs(settings);
perimeterCache = Plate.BuildPerimeterCache(plateView.Plate);
plateView.Invalidate();
}
private void OnKeyDown(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Escape)
if (e.KeyCode == Keys.Space)
{
lockedAxis = lockedAxis == CutOffAxis.Vertical
? CutOffAxis.Horizontal
: CutOffAxis.Vertical;
if (previewCutOff != null)
{
previewCutOff = new CutOff(plateView.CurrentPoint, lockedAxis);
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
plateView.Invalidate();
}
}
else if (e.KeyCode == Keys.Escape)
{
plateView.SetAction(typeof(ActionSelect));
}
}
private void OnPaint(object sender, PaintEventArgs e)
@@ -103,20 +140,5 @@ namespace OpenNest.Actions
}
}
}
private CutOffAxis DetectAxis(Vector pt)
{
var bounds = plateView.Plate.BoundingBox(includeParts: false);
var distToLeftRight = System.Math.Min(
System.Math.Abs(pt.X - bounds.X),
System.Math.Abs(pt.X - (bounds.X + bounds.Width)));
var distToTopBottom = System.Math.Min(
System.Math.Abs(pt.Y - bounds.Y),
System.Math.Abs(pt.Y - (bounds.Y + bounds.Length)));
return distToTopBottom < distToLeftRight
? CutOffAxis.Horizontal
: CutOffAxis.Vertical;
}
}
}