mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 03:51:05 -04:00
fix(planning): start an entry beside a contour vertex exactly at the vertex
Emission splits a contour at its entry with Arc/Line.SplitAt, which treat
a point within Tolerance.Epsilon (1e-5) of an endpoint as that endpoint
and start the contour there. The lead-in still ended at the entry point
itself, so an entry 1e-8..1e-5 from a vertex left that span uncut and the
plan's own verification refused it ("does not preserve complete directed
contours").
Imported PEP mounting holes hit this: two half circles whose stored centre
sits 5e-8 beside the chord's midpoint, so the closest entry to an approach
from straight below lands 5e-8 along the first arc from its start. Panel
doors were refused on every plate holding such a hole.
PreparedContours now snaps an entry 1e-8..1e-5 from its entity's start or
end to the exact vertex, for automatic entries, scrap pierce entries and
drawing pins alike, so the lead-in ends where the contour starts. Entries
already within 1e-8 are left as they were, so saved pinned programs
re-emit unchanged. Entries de-duplicate on the snapped point, and
near-corner insets keep the snap distance from both edge ends so none can
snap onto a reflex vertex.
This commit is contained in:
1 parent
39f28c7fa9
commit
45592a811c
2 files changed
+221
-5
No files matched your search
@@ -96,7 +96,9 @@ public sealed class PreparedContours
|
||||
rejected.Add(pin);
|
||||
continue;
|
||||
}
|
||||
var choice = new ContourChoice(contour, pin.EntityOrdinal, pin.Point) { Owner = this };
|
||||
var choice = new ContourChoice(contour, pin.EntityOrdinal,
|
||||
SnapToVertex(shapes[contour], pin.EntityOrdinal, pin.Point))
|
||||
{ Owner = this };
|
||||
ValidateChoice(choice);
|
||||
pinned[contour] = choice;
|
||||
pinNumbers[contour] = pin.PinIndex + 1;
|
||||
@@ -401,8 +403,12 @@ public sealed class PreparedContours
|
||||
void Add(int entityOrdinal, Vector point)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
|
||||
entries.Add(Entry(contourOrdinal, entityOrdinal, point));
|
||||
if (entries.Count >= maxEntries)
|
||||
return;
|
||||
// Compare the entry as emitted: snapping can bring two raw points together.
|
||||
var entry = Entry(contourOrdinal, entityOrdinal, point);
|
||||
if (!entries.Any(e => e.Point.DistanceTo(entry.Point) <= PostVerificationGeometry.Epsilon))
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +433,7 @@ public sealed class PreparedContours
|
||||
|
||||
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
|
||||
{
|
||||
point = SnapToVertex(GetShape(contourOrdinal), entityOrdinal, point);
|
||||
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
|
||||
ValidateChoice(choice);
|
||||
return choice;
|
||||
@@ -444,6 +451,7 @@ public sealed class PreparedContours
|
||||
PostVerificationGeometry.Validate(pierce);
|
||||
var shape = GetShape(PerimeterOrdinal);
|
||||
var point = shape.ClosestPointTo(pierce, out var entity);
|
||||
point = SnapToVertex(shape, shape.Entities.IndexOf(entity), point);
|
||||
var choice = new ContourChoice(PerimeterOrdinal, shape.Entities.IndexOf(entity), point)
|
||||
{
|
||||
Owner = this,
|
||||
@@ -630,9 +638,10 @@ public sealed class PreparedContours
|
||||
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
|
||||
// Strictly inside the edge by projection parameter (distance alone loses the
|
||||
// sign when a short edge is overshoot): never the corner, never the far
|
||||
// endpoint (where a reflex vertex might sit). Short edges omit the point.
|
||||
// endpoint (where a reflex vertex might sit). Short edges omit the point. The
|
||||
// margin is the vertex snap distance, so Entry cannot snap it onto either end.
|
||||
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
|
||||
if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
|
||||
if (t <= OpenNest.Math.Tolerance.Epsilon || t >= line.Length - OpenNest.Math.Tolerance.Epsilon)
|
||||
return;
|
||||
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
|
||||
}
|
||||
@@ -897,6 +906,33 @@ public sealed class PreparedContours
|
||||
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emission splits a contour at its entry with <see cref="Arc.SplitAt"/>/<see cref="Line.SplitAt"/>,
|
||||
/// which treat a point within <see cref="OpenNest.Math.Tolerance.Epsilon"/> of an endpoint as that endpoint and
|
||||
/// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
|
||||
/// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
|
||||
/// stored centre sits beside the chord's midpoint) is never cut.
|
||||
/// </summary>
|
||||
private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
|
||||
{
|
||||
if (entityOrdinal < 0 || entityOrdinal >= shape.Entities.Count
|
||||
|| shape.Entities[entityOrdinal] is not (Line or Arc))
|
||||
return point;
|
||||
var entity = shape.Entities[entityOrdinal];
|
||||
// Within the verifier's epsilon the entry already cuts the contour whole: leave it, so
|
||||
// programs emitted before this snap (saved pinned entries) re-emit unchanged.
|
||||
bool Snaps(Vector vertex)
|
||||
{
|
||||
var distance = point.DistanceTo(vertex);
|
||||
return distance > PostVerificationGeometry.Epsilon && distance < OpenNest.Math.Tolerance.Epsilon;
|
||||
}
|
||||
var start = Start(entity);
|
||||
if (point.DistanceTo(start) < OpenNest.Math.Tolerance.Epsilon)
|
||||
return Snaps(start) ? start : point;
|
||||
var end = End(entity);
|
||||
return Snaps(end) ? end : point;
|
||||
}
|
||||
|
||||
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
|
||||
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
// A 0.277 in mounting hole imported from PEP (a panel door): two half circles whose stored
|
||||
// centre sits 5e-8 beside the chord's midpoint. The closest entry to an approach from straight
|
||||
// below lands on the hole's lowest point, 5e-8 along the first arc from its start. Emission
|
||||
// splits the contour there with the 1e-5 endpoint tolerance, so the contour starts at the vertex;
|
||||
// the entry must start there too, or the 5e-8 between them is never cut and the plan's own
|
||||
// verification refuses it.
|
||||
public class ImportedArcHoleTests
|
||||
{
|
||||
private const double Offset = 4.94e-8;
|
||||
private static readonly Vector Bottom = new(1, 0.5);
|
||||
private static readonly Vector Top = new(1, 0.777);
|
||||
|
||||
internal static CuttingParameters SweepParameters()
|
||||
{
|
||||
var lead = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
|
||||
return new CuttingParameters
|
||||
{
|
||||
ExternalLeadIn = lead,
|
||||
InternalLeadIn = lead,
|
||||
ArcCircleLeadIn = lead,
|
||||
ExternalLeadOut = new NoLeadOut(),
|
||||
InternalLeadOut = new NoLeadOut(),
|
||||
ArcCircleLeadOut = new NoLeadOut(),
|
||||
PierceClearance = 0.0625
|
||||
};
|
||||
}
|
||||
|
||||
private static Program PartWithHole(double offset, bool holeBelow = false)
|
||||
{
|
||||
var p = new Program();
|
||||
p.MoveTo(0, 0); p.LineTo(2, 0); p.LineTo(2, 2); p.LineTo(0, 2); p.LineTo(0, 0);
|
||||
if (holeBelow)
|
||||
{
|
||||
// A smaller hole straight below, cut first, so the mounting hole is approached from below.
|
||||
p.MoveTo(1 + offset, 0.1);
|
||||
p.ArcTo(new Vector(1 + offset, 0.3), new Vector(1 + offset, 0.2), RotationType.CCW);
|
||||
p.ArcTo(new Vector(1 + offset, 0.1), new Vector(1 + offset, 0.2), RotationType.CCW);
|
||||
}
|
||||
var centre = new Vector(1 + offset, (Bottom.Y + Top.Y) / 2);
|
||||
p.MoveTo(Bottom.X, Bottom.Y);
|
||||
p.ArcTo(Top, centre, RotationType.CCW);
|
||||
p.ArcTo(Bottom, centre, RotationType.CCW);
|
||||
return p;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Offset)]
|
||||
[InlineData(-Offset)]
|
||||
public void EntryWithinTheSplitToleranceOfAVertex_StartsExactlyAtTheVertex(double offset)
|
||||
{
|
||||
var prepared = PreparedContours.Capture(PartWithHole(offset), SweepParameters());
|
||||
var hole = Enumerable.Range(0, prepared.Count).Single(i => i != prepared.PerimeterOrdinal);
|
||||
|
||||
var choice = prepared.ClosestEntry(hole, new Vector(1 + offset, 0.2));
|
||||
|
||||
Assert.Equal(Bottom.X, choice.Point.X, 12);
|
||||
Assert.Equal(Bottom.Y, choice.Point.Y, 12);
|
||||
}
|
||||
|
||||
// The same holds for the other entries the planner does not pick from vertices: a drawing pin
|
||||
// and a perimeter pierce placed in scrap whose closest contour point lies beside a corner.
|
||||
[Fact]
|
||||
public void PinBesideACorner_StartsExactlyAtTheCorner()
|
||||
{
|
||||
var drawing = PinnedContourTests.SquareWithPin(new Vector(3e-6, 0), PinnedContourTests.Corner());
|
||||
var plain = PreparedContours.Capture(drawing.Program, PinnedContourTests.NestSettings());
|
||||
var prepared = PreparedContours.Capture(drawing.Program, PinnedContourTests.NestSettings(),
|
||||
CutPinResolver.Resolve(drawing, 0, plain));
|
||||
|
||||
var pinned = Assert.IsType<ContourChoice>(prepared.PinnedEntry(prepared.PerimeterOrdinal));
|
||||
|
||||
Assert.Equal(0, pinned.Point.DistanceTo(Vector.Zero), 12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrapPierceBesideACorner_EntersExactlyAtTheCorner()
|
||||
{
|
||||
var prepared = PreparedContours.Capture(ExplicitContourTests.Square(false), PinnedContourTests.NestSettings());
|
||||
|
||||
var choice = prepared.ScrapPierceEntry(new Vector(-0.5, 3e-6));
|
||||
|
||||
Assert.Equal(0, choice.Point.DistanceTo(Vector.Zero), 12);
|
||||
}
|
||||
|
||||
// Snapping must not hand automatic selection a forbidden vertex: a near-corner inset on an edge
|
||||
// only just longer than twice the lead-in stays clear of the reflex corner at its far end.
|
||||
[Fact]
|
||||
public void NearCornerInset_NeverSnapsOntoAReflexVertex()
|
||||
{
|
||||
var reflex = new Vector(1.699995, 1);
|
||||
var p = new Program();
|
||||
p.MoveTo(0, 0); p.LineTo(2, 0); p.LineTo(2, 1); p.LineTo(reflex.X, reflex.Y);
|
||||
p.LineTo(reflex.X, 2); p.LineTo(0, 2); p.LineTo(0, 0);
|
||||
var prepared = PreparedContours.Capture(p, SweepParameters());
|
||||
|
||||
var candidates = prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal);
|
||||
|
||||
Assert.DoesNotContain(candidates, c => c.Choice.Point.DistanceTo(reflex) <= 1e-9);
|
||||
}
|
||||
|
||||
// De-duplication compares the entries as emitted (snapped), so an edge shorter than the snap
|
||||
// distance does not offer its start twice.
|
||||
[Fact]
|
||||
public void Entries_OfferEachSnappedPointOnce()
|
||||
{
|
||||
var p = new Program();
|
||||
p.MoveTo(0, 0); p.LineTo(1, 0); p.LineTo(1, 1); p.LineTo(0, 1); p.LineTo(0, 5e-6); p.LineTo(0, 0);
|
||||
var prepared = PreparedContours.Capture(p, SweepParameters());
|
||||
|
||||
var points = prepared.Entries(prepared.PerimeterOrdinal, new Vector(-1, -1)).Select(e => e.Point).ToList();
|
||||
|
||||
for (var i = 0; i < points.Count; i++)
|
||||
for (var j = i + 1; j < points.Count; j++)
|
||||
Assert.True(points[i].DistanceTo(points[j]) > 1e-8, $"{points[i]} offered twice");
|
||||
}
|
||||
|
||||
// An entry already within the verifier's epsilon of a vertex was cut whole before; it is left
|
||||
// exactly where it was, so saved pinned programs re-emit unchanged.
|
||||
[Fact]
|
||||
public void EntryWithinVerifierEpsilonOfAVertex_IsUnchanged()
|
||||
{
|
||||
var prepared = PreparedContours.Capture(ExplicitContourTests.Square(false), PinnedContourTests.NestSettings());
|
||||
var point = new Vector(-0.5, 5e-9);
|
||||
|
||||
var choice = prepared.ScrapPierceEntry(point);
|
||||
|
||||
Assert.Equal(5e-9, choice.Point.Y, 15);
|
||||
}
|
||||
|
||||
// The emitted hole is whole: its cut, from the lead-in's end, covers the drawn contour's full length.
|
||||
[Theory]
|
||||
[InlineData(Offset)]
|
||||
[InlineData(-Offset)]
|
||||
public void EntryNearAVertex_EmitsTheWholeHole(double offset)
|
||||
{
|
||||
var prepared = PreparedContours.Capture(PartWithHole(offset), SweepParameters());
|
||||
var hole = Enumerable.Range(0, prepared.Count).Single(i => i != prepared.PerimeterOrdinal);
|
||||
var choice = prepared.ClosestEntry(hole, new Vector(1 + offset, 0.2));
|
||||
|
||||
var motions = ExecutionMotionReader.Read(prepared.EmitCandidateForValidation(choice), Vector.Zero, null, default).Motions;
|
||||
|
||||
// Emission marks contour motion Display; replay counts it as cut, as the verifier does.
|
||||
var cuts = motions.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).ToList();
|
||||
var lead = motions.Last(m => m.Layer == LayerType.Leadin);
|
||||
var nominal = OpenNest.Converters.ConvertProgram.ToGeometry(PartWithHole(offset))
|
||||
.OfType<Arc>().Sum(a => a.Length);
|
||||
Assert.Equal(nominal, cuts.Sum(m => m.Length), 12);
|
||||
Assert.Equal(0, lead.End.DistanceTo(cuts[0].Curve.Start), 12);
|
||||
Assert.Equal(0, cuts[^1].End.DistanceTo(cuts[0].Curve.Start), 12);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Offset)]
|
||||
[InlineData(-Offset)]
|
||||
[InlineData(0)]
|
||||
public void ImportedMountingHole_PlansAndApplies(double offset)
|
||||
{
|
||||
foreach (var below in new[] { false, true })
|
||||
PlansAndApplies(PartWithHole(offset, below));
|
||||
}
|
||||
|
||||
private static void PlansAndApplies(Program program)
|
||||
{
|
||||
var plate = new Plate(new Size(10, 10));
|
||||
plate.Parts.Add(new Part(new Drawing("hole", program), new Vector(1, 1)));
|
||||
|
||||
var proposal = CuttingPlanBatch.Capture([plate], SweepParameters(), false).Plan();
|
||||
|
||||
Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
|
||||
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: false).Status);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user