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
+76 -23
View File
@@ -26,9 +26,9 @@ namespace OpenNest
Drawing = new Drawing(GetName()) { IsCutOff = true }; 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); var program = BuildProgram(segments, settings);
Drawing.Program = program; Drawing.Program = program;
} }
@@ -40,7 +40,7 @@ namespace OpenNest
return $"CutOff-{axisChar}-{coord:F2}"; 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); var bounds = plate.BoundingBox(includeParts: false);
@@ -66,26 +66,10 @@ namespace OpenNest
if (part.BaseDrawing.IsCutOff) if (part.BaseDrawing.IsCutOff)
continue; continue;
var bb = part.BoundingBox; Entity perimeter = null;
double partStart, partEnd, partMin, partMax; cache?.TryGetValue(part, out perimeter);
var partExclusions = GetPartExclusions(part, perimeter, cutPosition, lineStart, lineEnd, settings.PartClearance);
if (Axis == CutOffAxis.Vertical) exclusions.AddRange(partExclusions);
{
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));
} }
exclusions.Sort((a, b) => a.Start.CompareTo(b.Start)); exclusions.Sort((a, b) => a.Start.CompareTo(b.Start));
@@ -120,6 +104,75 @@ namespace OpenNest
return segments; 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) private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings)
{ {
var program = new Program(); var program = new Program();
+1 -2
View File
@@ -249,8 +249,7 @@ namespace OpenNest.Geometry
foreach (var geo in shape.Entities) foreach (var geo in shape.Entities)
{ {
List<Vector> pts3; if (geo.Intersects(line, out var pts3))
geo.Intersects(line, out pts3);
pts.AddRange(pts3); pts.AddRange(pts3);
} }
+11 -32
View File
@@ -86,7 +86,7 @@ public class CutOffGeometryTests
part.Location = new Vector(0, 0); part.Location = new Vector(0, 0);
plate.Parts.Add(part); 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. // Cut at X=2: inside the BB but near the edge of the circle.
var cutoff = new CutOff(new Vector(2, 0), CutOffAxis.Vertical); var cutoff = new CutOff(new Vector(2, 0), CutOffAxis.Vertical);
@@ -125,7 +125,7 @@ public class CutOffGeometryTests
part.Location = new Vector(0, 0); part.Location = new Vector(0, 0);
plate.Parts.Add(part); plate.Parts.Add(part);
var cache = CutOff.BuildPerimeterCache(plate); var cache = Plate.BuildPerimeterCache(plate);
var cutoff = new CutOff(new Vector(5, 0), CutOffAxis.Vertical); var cutoff = new CutOff(new Vector(5, 0), CutOffAxis.Vertical);
cutoff.Regenerate(plate, ZeroClearance, cache); cutoff.Regenerate(plate, ZeroClearance, cache);
@@ -157,7 +157,7 @@ public class CutOffGeometryTests
part.Location = new Vector(10, 10); part.Location = new Vector(10, 10);
plate.Parts.Add(part); plate.Parts.Add(part);
var cache = CutOff.BuildPerimeterCache(plate); var cache = Plate.BuildPerimeterCache(plate);
var cutoff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical); var cutoff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
cutoff.Regenerate(plate, ZeroClearance, cache); cutoff.Regenerate(plate, ZeroClearance, cache);
@@ -202,7 +202,7 @@ public class CutOffGeometryTests
part.Location = new Vector(0, 0); part.Location = new Vector(0, 0);
plate.Parts.Add(part); 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. // Horizontal cut at Y=2: near the edge of the circle.
var cutoff = new CutOff(new Vector(0, 2), CutOffAxis.Horizontal); var cutoff = new CutOff(new Vector(0, 2), CutOffAxis.Horizontal);
@@ -233,7 +233,7 @@ public class CutOffGeometryTests
plate.Parts.Add(part); plate.Parts.Add(part);
var settings = new CutOffSettings { PartClearance = 5.0 }; 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); var cutoff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
cutoff.Regenerate(plate, settings, cache); cutoff.Regenerate(plate, settings, cache);
@@ -243,32 +243,8 @@ public class CutOffGeometryTests
} }
[Fact] [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(); var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0))); pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0))); pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
@@ -277,9 +253,12 @@ public class CutOffGeometryTests
var plate = new Plate(100, 100); var plate = new Plate(100, 100);
plate.Parts.Add(new Part(new Drawing("open", pgm))); plate.Parts.Add(new Part(new Drawing("open", pgm)));
var cache = CutOff.BuildPerimeterCache(plate); var cache = Plate.BuildPerimeterCache(plate);
Assert.Single(cache); Assert.Single(cache);
Assert.Null(cache[0]);
var perimeter = cache[plate.Parts[0]];
Assert.NotNull(perimeter);
Assert.IsType<Polygon>(perimeter);
} }
[Fact] [Fact]
+44 -22
View File
@@ -1,6 +1,7 @@
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Controls; using OpenNest.Controls;
using OpenNest.Geometry; using OpenNest.Geometry;
using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Drawing; using System.Drawing;
using System.Drawing.Drawing2D; using System.Drawing.Drawing2D;
@@ -13,11 +14,18 @@ namespace OpenNest.Actions
{ {
private CutOff previewCutOff; private CutOff previewCutOff;
private CutOffSettings settings; private CutOffSettings settings;
private CutOffAxis lockedAxis = CutOffAxis.Vertical;
private Dictionary<Part, Entity> perimeterCache;
private readonly Timer debounceTimer;
private bool regeneratePending;
public ActionCutOff(PlateView plateView) public ActionCutOff(PlateView plateView)
: base(plateView) : base(plateView)
{ {
settings = plateView.CutOffSettings; settings = plateView.CutOffSettings;
perimeterCache = Plate.BuildPerimeterCache(plateView.Plate);
debounceTimer = new Timer { Interval = 16 };
debounceTimer.Tick += OnDebounce;
plateView.MouseMove += OnMouseMove; plateView.MouseMove += OnMouseMove;
plateView.MouseDown += OnMouseDown; plateView.MouseDown += OnMouseDown;
@@ -27,6 +35,8 @@ namespace OpenNest.Actions
public override void ConnectEvents() public override void ConnectEvents()
{ {
perimeterCache = Plate.BuildPerimeterCache(plateView.Plate);
plateView.MouseMove += OnMouseMove; plateView.MouseMove += OnMouseMove;
plateView.MouseDown += OnMouseDown; plateView.MouseDown += OnMouseDown;
plateView.KeyDown += OnKeyDown; plateView.KeyDown += OnKeyDown;
@@ -35,12 +45,14 @@ namespace OpenNest.Actions
public override void DisconnectEvents() public override void DisconnectEvents()
{ {
debounceTimer.Stop();
plateView.MouseMove -= OnMouseMove; plateView.MouseMove -= OnMouseMove;
plateView.MouseDown -= OnMouseDown; plateView.MouseDown -= OnMouseDown;
plateView.KeyDown -= OnKeyDown; plateView.KeyDown -= OnKeyDown;
plateView.Paint -= OnPaint; plateView.Paint -= OnPaint;
previewCutOff = null; previewCutOff = null;
perimeterCache = null;
plateView.Invalidate(); plateView.Invalidate();
} }
@@ -50,11 +62,21 @@ namespace OpenNest.Actions
private void OnMouseMove(object sender, MouseEventArgs e) private void OnMouseMove(object sender, MouseEventArgs e)
{ {
var pt = plateView.CurrentPoint; regeneratePending = true;
var axis = DetectAxis(pt); debounceTimer.Start();
}
previewCutOff = new CutOff(pt, axis); private void OnDebounce(object sender, System.EventArgs e)
previewCutOff.Regenerate(plateView.Plate, settings); {
debounceTimer.Stop();
if (!regeneratePending)
return;
regeneratePending = false;
var pt = plateView.CurrentPoint;
previewCutOff = new CutOff(pt, lockedAxis);
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
plateView.Invalidate(); plateView.Invalidate();
} }
@@ -64,19 +86,34 @@ namespace OpenNest.Actions
return; return;
var pt = plateView.CurrentPoint; var pt = plateView.CurrentPoint;
var axis = DetectAxis(pt); var cutoff = new CutOff(pt, lockedAxis);
var cutoff = new CutOff(pt, axis);
plateView.Plate.CutOffs.Add(cutoff); plateView.Plate.CutOffs.Add(cutoff);
plateView.Plate.RegenerateCutOffs(settings); plateView.Plate.RegenerateCutOffs(settings);
perimeterCache = Plate.BuildPerimeterCache(plateView.Plate);
plateView.Invalidate(); plateView.Invalidate();
} }
private void OnKeyDown(object sender, KeyEventArgs e) 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)); plateView.SetAction(typeof(ActionSelect));
} }
}
private void OnPaint(object sender, PaintEventArgs e) 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;
}
} }
} }