mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-05 06:42:26 -04:00
fix(cutting): certify complete selected contour programs
This commit is contained in:
1 parent
9f7580a486
commit
e8d974a2db
8 files changed
+538
-47
No files matched your search
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Geometry;
|
||||
using Curve = OpenNest.Diagnostics.PostVerificationGeometry.Curve;
|
||||
|
||||
namespace OpenNest.CNC.CuttingPlanning;
|
||||
|
||||
/// <summary>Independently constructed expected geometry, frozen before final replay.
|
||||
/// It is not a search verdict or the payload returned to the caller.</summary>
|
||||
internal sealed class SelectedContourProgram
|
||||
{
|
||||
private readonly PreparedContours owner;
|
||||
private readonly ContourChoice[] choices;
|
||||
internal OwnedExecution Expected { get; }
|
||||
internal double TabSize { get; }
|
||||
|
||||
internal SelectedContourProgram(PreparedContours owner, IReadOnlyList<ContourChoice> choices,
|
||||
OwnedExecution expected, double tabSize)
|
||||
{
|
||||
this.owner = owner;
|
||||
this.choices = choices.ToArray();
|
||||
Expected = expected;
|
||||
TabSize = tabSize;
|
||||
}
|
||||
|
||||
internal bool Matches(PreparedContours prepared, IReadOnlyList<ContourChoice> selected)
|
||||
{
|
||||
if (!ReferenceEquals(owner, prepared) || !choices.SequenceEqual(selected)) return false;
|
||||
prepared.ValidateCompleteChoices(selected);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Directed native boundary accounting and selected-emission correspondence.
|
||||
/// No tessellation, topology inference, live source reads or final emission.</summary>
|
||||
internal static class ContourProgramVerifier
|
||||
{
|
||||
private const double Epsilon = PostVerificationGeometry.Epsilon;
|
||||
|
||||
internal static bool Verify(OwnedExecution actual, LeadMaterialSnapshot material,
|
||||
SelectedContourProgram selected, CancellationToken token)
|
||||
{
|
||||
var budget = 1000000;
|
||||
if (material == null || !material.IsComplete) return false;
|
||||
if (selected != null && !SameEmission(actual, selected.Expected, token, ref budget)) return false;
|
||||
var visited = new HashSet<int>();
|
||||
foreach (var run in Runs(actual).Where(r => !r.Rapid && r.Kind == LayerType.Cut))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (run.Curves.Count == 0) return false;
|
||||
var first = run.Curves[0];
|
||||
var ringIndex = -1;
|
||||
var entityIndex = -1;
|
||||
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
|
||||
for (var e = 0; e < material.Rings[r].Length; e++)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
var curve = material.Rings[r][e];
|
||||
if (curve.SameDirection(first) && curve.Contains(first.Start)
|
||||
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
||||
{
|
||||
ringIndex = r;
|
||||
entityIndex = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ringIndex < 0 || !visited.Add(ringIndex)) return false;
|
||||
var ring = material.Rings[ringIndex];
|
||||
var nominalLength = ring.Sum(c => c.Length);
|
||||
var cutLength = run.Curves.Sum(c => c.Length);
|
||||
var missing = nominalLength - cutLength;
|
||||
if (missing < -Epsilon) return false; // Never a second circuit/retrace.
|
||||
if (missing > Epsilon)
|
||||
{
|
||||
// Fixed legacy payloads have no certified selected tab/entry metadata.
|
||||
// A merely open contour or a caller's current tab switch proves nothing.
|
||||
if (selected == null || ringIndex != 0 || selected.TabSize <= 0
|
||||
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > Epsilon)
|
||||
return false;
|
||||
// SameEmission has already bound this exact terminal gap to the owned
|
||||
// settings' independently emitted, rounded/clamped selected geometry.
|
||||
}
|
||||
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
||||
true, token, ref budget)) return false;
|
||||
}
|
||||
return visited.Count == material.Rings.Count;
|
||||
}
|
||||
|
||||
private static bool SameEmission(OwnedExecution actual, OwnedExecution expected,
|
||||
CancellationToken token, ref int budget)
|
||||
{
|
||||
var left = Runs(actual);
|
||||
var right = Runs(expected);
|
||||
if (left.Count != right.Count) return false;
|
||||
for (var i = 0; i < left.Count; i++)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
var a = left[i];
|
||||
var b = right[i];
|
||||
if (a.Kind != b.Kind || a.Rapid != b.Rapid) return false;
|
||||
if (a.Rapid)
|
||||
{
|
||||
// Arrival is supplied anew by replay, not captured expected geometry.
|
||||
if (a.End.DistanceTo(b.End) > Epsilon) return false;
|
||||
}
|
||||
else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon
|
||||
|| !Follows(a.Curves, b.Curves, 0, 0, false, token, ref budget)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Consume directed native arc-length from both streams. Subdivision, merged
|
||||
// collinear moves and a cyclic ring's reindexing do not change coverage.
|
||||
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
|
||||
int index, double offset, bool cyclic, CancellationToken token, ref int budget)
|
||||
{
|
||||
foreach (var curve in actual)
|
||||
{
|
||||
var consumed = 0.0;
|
||||
while (consumed < curve.Length - Epsilon)
|
||||
{
|
||||
Query(token, ref budget);
|
||||
if (index >= expected.Count)
|
||||
{
|
||||
if (!cyclic) return false;
|
||||
index = 0;
|
||||
}
|
||||
var nominal = expected[index];
|
||||
var remaining = nominal.Length - offset;
|
||||
if (remaining <= 0)
|
||||
{
|
||||
index++;
|
||||
offset = 0;
|
||||
continue;
|
||||
}
|
||||
var length = System.Math.Min(remaining, curve.Length - consumed);
|
||||
if (!nominal.SameDirection(curve)
|
||||
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > Epsilon
|
||||
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > Epsilon)
|
||||
return false;
|
||||
consumed += length;
|
||||
offset += length;
|
||||
if (offset >= nominal.Length - Epsilon)
|
||||
{
|
||||
index++;
|
||||
offset = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cyclic || index == expected.Count || index == expected.Count - 1
|
||||
&& expected[index].Length - offset <= Epsilon;
|
||||
}
|
||||
|
||||
private static List<Run> Runs(OwnedExecution execution)
|
||||
{
|
||||
var runs = new List<Run>();
|
||||
foreach (var motion in execution.Motions)
|
||||
{
|
||||
var kind = motion.Layer is LayerType.Cut or LayerType.Display ? LayerType.Cut : motion.Layer;
|
||||
if (motion.Rapid)
|
||||
{
|
||||
runs.Add(new Run(true, kind, motion.End));
|
||||
continue;
|
||||
}
|
||||
if (motion.Length <= Epsilon) continue; // Zero travel contributes no coverage.
|
||||
if (runs.Count == 0 || runs[^1].Rapid || runs[^1].Kind != kind)
|
||||
runs.Add(new Run(false, kind, motion.End));
|
||||
runs[^1].Curves.Add(motion.Curve);
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
private static void Query(CancellationToken token, ref int budget)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0) throw new NotSupportedException("Replay contour accounting exceeds the native query limit.");
|
||||
}
|
||||
|
||||
private sealed record Run(bool Rapid, LayerType Kind, Vector End)
|
||||
{
|
||||
internal List<Curve> Curves { get; } = new();
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,18 @@ public sealed class PreparedContours
|
||||
shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices);
|
||||
}
|
||||
|
||||
internal void ValidateCompleteChoices(IReadOnlyList<ContourChoice> choices) => Validate(choices, true);
|
||||
|
||||
// Build expected-emission metadata independently of the selected payload BEFORE
|
||||
// replay. Final verification only reads this immutable execution and clean geometry.
|
||||
internal SelectedContourProgram CaptureSelectedProgram(IReadOnlyList<ContourChoice> choices,
|
||||
Vector location, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var expected = ExecutionMotionReader.ReadSupported(Emit(choices), location, null, token);
|
||||
return new(this, choices, expected, parameters.TabsEnabled ? parameters.TabConfig.Size : 0);
|
||||
}
|
||||
|
||||
private void Validate(IReadOnlyList<ContourChoice> choices, bool complete)
|
||||
{
|
||||
if (choices == null || (complete && choices.Count != Count) || choices.Count > Count)
|
||||
|
||||
@@ -99,6 +99,26 @@ internal static class PostVerificationGeometry
|
||||
&& System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon;
|
||||
}
|
||||
|
||||
// Directed native arc-length coordinates for contour replay, not collision queries.
|
||||
internal bool SameDirection(Curve other) => SameSupport(other)
|
||||
&& (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
|
||||
: Dot(End - Start, other.End - other.Start) > 0);
|
||||
|
||||
internal double DistanceAlong(Vector point) => Center is { } center
|
||||
? point.DistanceTo(Start) <= Epsilon ? 0
|
||||
: Travel(System.Math.Atan2(point.Y - center.Y, point.X - center.X)) * Radius
|
||||
: Dot(point - Start, (End - Start) * (1 / Length));
|
||||
|
||||
internal Vector PointAtLength(double distance)
|
||||
{
|
||||
if (distance <= 0) return Start;
|
||||
if (distance >= Length) return End;
|
||||
return Center is { } center
|
||||
? center + new Vector(System.Math.Cos(StartAngle + System.Math.Sign(Sweep) * distance / Radius),
|
||||
System.Math.Sin(StartAngle + System.Math.Sign(Sweep) * distance / Radius)) * Radius
|
||||
: Start + (End - Start) * (distance / Length);
|
||||
}
|
||||
|
||||
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon;
|
||||
|
||||
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
||||
|
||||
@@ -94,8 +94,27 @@ public sealed class FixedProgramPlacement
|
||||
public bool IsRegenerated => ContourChoices.Count != 0;
|
||||
/// <summary>Returns an independent deep copy; never an alias to captured/proposed code.</summary>
|
||||
public Program CopyProgram() => program == null ? null : OwnedProgramCopy.Copy(program);
|
||||
internal FixedProgramPlacement Propose(Program proposed, OwnedExecution execution, IReadOnlyList<ContourChoice> choices) =>
|
||||
new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution, OwnedProgramCopy.Copy(proposed), Prepared, Material, choices);
|
||||
internal SelectedContourProgram SelectedProgram { get; private init; }
|
||||
|
||||
internal FixedProgramPlacement Propose(Program proposed, OwnedExecution execution, IReadOnlyList<ContourChoice> choices,
|
||||
System.Threading.CancellationToken token = default)
|
||||
{
|
||||
SelectedContourProgram selected = null;
|
||||
try
|
||||
{
|
||||
// The expected program is constructed from owned choices/settings, NEVER
|
||||
// from proposed or its cached execution. Counterfeit payloads remain subject
|
||||
// to independent final checks, including callers of this internal test seam.
|
||||
selected = Prepared?.CaptureSelectedProgram(choices, Location, token);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or InvalidOperationException or ArithmeticException)
|
||||
{
|
||||
// Invalid metadata is a refused proposal, not an exception from final replay.
|
||||
}
|
||||
return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution,
|
||||
OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices)
|
||||
{ SelectedProgram = selected };
|
||||
}
|
||||
|
||||
public Part SourcePart { get; }
|
||||
public int SourceOrdinal { get; }
|
||||
|
||||
@@ -201,18 +201,15 @@ public static class CuttingPlanService
|
||||
if (!ReferenceEquals(source, proposal))
|
||||
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
||||
}
|
||||
else if (proposal.ContourChoices.Count != source.Prepared.Count
|
||||
|| proposal.ContourChoices.Any(c => !ReferenceEquals(c.Owner, source.Prepared)
|
||||
|| c.ContourOrdinal < 0 || c.ContourOrdinal >= source.Prepared.Count)
|
||||
|| proposal.ContourChoices.Select(c => c.ContourOrdinal).Distinct().Count() != source.Prepared.Count
|
||||
|| proposal.ContourChoices[^1].ContourOrdinal != source.Prepared.PerimeterOrdinal)
|
||||
else if (proposal.SelectedProgram == null
|
||||
|| !proposal.SelectedProgram.Matches(source.Prepared, proposal.ContourChoices))
|
||||
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
||||
OwnedExecution execution;
|
||||
bool complete;
|
||||
try
|
||||
{
|
||||
execution = ExecutionMotionReader.Read(proposal.CopyProgram(), proposal.Location, position, token);
|
||||
if (source.Prepared != null && !HasCompleteContourAccounting(execution, source.Material, token))
|
||||
return new(CuttingPlanStatus.InvalidInput, expansions: expansions);
|
||||
complete = ContourProgramVerifier.Verify(execution, source.Material, proposal.SelectedProgram, token);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||
{
|
||||
@@ -225,6 +222,11 @@ public static class CuttingPlanService
|
||||
if (!lead.IsComplete || !lead.IsClear)
|
||||
findings.Add(JointCuttingPlanSearch.Finding(source,
|
||||
lead.IsComplete ? null : PostVerificationKind.Incomplete, lead.Reason));
|
||||
if (!complete && findings.Count == 0)
|
||||
return new(CuttingPlanStatus.InvalidInput,
|
||||
findings: [JointCuttingPlanSearch.Finding(source, null,
|
||||
"Selected program does not preserve complete directed contours, certified tabs or selected entry/style geometry.")],
|
||||
expansions: expansions);
|
||||
distance += execution.RapidDistanceFrom(position);
|
||||
position = execution.DeparturePoint;
|
||||
}
|
||||
@@ -237,42 +239,6 @@ public static class CuttingPlanService
|
||||
rapidDistance: distance, independentlyReplayed: true);
|
||||
}
|
||||
|
||||
private static bool HasCompleteContourAccounting(OwnedExecution execution, LeadMaterialSnapshot material, CancellationToken token)
|
||||
{
|
||||
var visited = new HashSet<int>();
|
||||
var current = -1;
|
||||
var budget = 1000000;
|
||||
foreach (var move in execution.Motions)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (move.Rapid || move.Layer is not (LayerType.Cut or LayerType.Display))
|
||||
{
|
||||
current = -1;
|
||||
continue;
|
||||
}
|
||||
var ring = -1;
|
||||
for (var i = 0; i < material.Rings.Count; i++)
|
||||
if (material.Rings[i].Any(c => Matches(c, move)))
|
||||
{
|
||||
ring = i;
|
||||
break;
|
||||
}
|
||||
if (ring < 0 || current >= 0 && ring != current || current < 0 && !visited.Add(ring))
|
||||
return false;
|
||||
current = ring;
|
||||
}
|
||||
return visited.Count == material.Rings.Count;
|
||||
|
||||
bool Matches(PostVerificationGeometry.Curve curve, ExecutionMotion move)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0) throw new NotSupportedException("Replay contour accounting exceeds the native query limit.");
|
||||
return curve.SameSupport(move.Curve) && curve.Contains(move.Curve.Start)
|
||||
&& curve.Contains(move.End) && curve.Contains(move.Curve.Midpoint)
|
||||
&& move.Length <= curve.Length + PostVerificationGeometry.Epsilon;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SameBits(double source, double proposed) =>
|
||||
BitConverter.DoubleToInt64Bits(source) == BitConverter.DoubleToInt64Bits(proposed);
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ internal static class JointCuttingPlanSearch
|
||||
if (!Check(source, execution, arrival, checker)) continue;
|
||||
var distance = execution.RapidDistanceFrom(arrival);
|
||||
var next = prefix.Length == prepared.Count
|
||||
? new Node([.. node.Order, source.Propose(program, execution, prefix)], execution.DeparturePoint, checker, null)
|
||||
? new Node([.. node.Order, source.Propose(program, execution, prefix, token)], execution.DeparturePoint, checker, null)
|
||||
: new Node(node.Order, execution.DeparturePoint, checker,
|
||||
new(source, prefix, arrival, before, distance));
|
||||
yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal, contour, entry);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class CuttingReplayContractTests
|
||||
{
|
||||
private static readonly Vector Start = new(-2, 5);
|
||||
|
||||
[Theory]
|
||||
[InlineData("partial")]
|
||||
[InlineData("retrace")]
|
||||
[InlineData("reverse")]
|
||||
[InlineData("twice")]
|
||||
[InlineData("wrong-point")]
|
||||
[InlineData("wrong-index")]
|
||||
[InlineData("different-entry")]
|
||||
[InlineData("short-lead")]
|
||||
public void Replay_RejectsCounterfeitSelectedPayload(string fault)
|
||||
{
|
||||
var (part, settings) = Fixture();
|
||||
var before = ExplicitContourTests.Fingerprint(part.Program);
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
|
||||
var source = snapshot.Placements[0];
|
||||
var choices = new[] { source.Prepared.ClosestEntry(0, Start) };
|
||||
var program = source.Prepared.Emit(choices);
|
||||
switch (fault)
|
||||
{
|
||||
case "partial": program = Path(new Vector(0, 10)); break;
|
||||
case "retrace": program = Path(new(0, 8), new(0, 7), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)); break;
|
||||
case "reverse": program = Path(new(0, 0), new(10, 0), new(10, 10), new(0, 10), new(0, 5)); break;
|
||||
case "twice": program = Path(new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)); break;
|
||||
case "wrong-point": choices[0] = choices[0] with { Point = new Vector(999, 999) }; break;
|
||||
case "wrong-index": choices[0] = choices[0] with { EntityOrdinal = 999 }; break;
|
||||
case "different-entry": choices[0] = source.Prepared.Entry(0, 0, new Vector(0, 6)); break;
|
||||
case "short-lead":
|
||||
program.Mode = Mode.Absolute;
|
||||
var rapid = program.Codes.OfType<RapidMove>().First();
|
||||
var lead = program.Codes.OfType<LinearMove>().First(m => m.Layer == LayerType.Leadin);
|
||||
rapid.EndPoint = lead.EndPoint + (rapid.EndPoint - lead.EndPoint) * 0.5;
|
||||
break;
|
||||
}
|
||||
var result = CuttingPlanService.ReplayPrograms(snapshot, [source.Propose(program, Read(program), choices)], 0, default);
|
||||
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.Empty(result.ProposedOrder);
|
||||
Assert.False(result.IndependentlyReplayed);
|
||||
Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, "partial")]
|
||||
[InlineData(false, "partial")]
|
||||
[InlineData(true, "reverse")]
|
||||
[InlineData(false, "retrace")]
|
||||
public void ConfirmedFixedProgram_RejectsIncompleteOrWrongDirectedCoverageWithoutRepair(bool locked, string fault)
|
||||
{
|
||||
var (part, settings) = Fixture();
|
||||
var program = fault switch
|
||||
{
|
||||
"reverse" => Path(new(0, 0), new(10, 0), new(10, 10), new(0, 10), new(0, 5)),
|
||||
"retrace" => Path(new(0, 8), new(0, 7), new(0, 10), new(10, 10), new(10, 0), new(0, 0), new(0, 5)),
|
||||
_ => Path(new Vector(0, 10))
|
||||
};
|
||||
Assert.True(part.RestoreLeadInProgram(program, locked));
|
||||
var before = ExplicitContourTests.Fingerprint(part.Program);
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings,
|
||||
eligibleParts: locked ? null : []));
|
||||
var result = CuttingPlanService.Plan(snapshot);
|
||||
Assert.NotEqual(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.NotEqual(CuttingPlanStatus.Ready, CuttingPlanService.ReplayPrograms(snapshot, snapshot.Placements, 0, default).Status);
|
||||
Assert.Empty(result.ProposedOrder);
|
||||
Assert.Equal(before, ExplicitContourTests.Fingerprint(part.Program));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_RejectsDifferentActualContourOrderWithUnchangedChoices()
|
||||
{
|
||||
var settings = ExplicitContourTests.Parameters();
|
||||
var part = new Part(new Drawing("holes", PreparedContourTests.Holes()));
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
|
||||
var source = snapshot.Placements[0];
|
||||
var choices = Enumerable.Range(0, 3).Select(i => source.Prepared.ClosestEntry(i, Start)).ToArray();
|
||||
var swapped = new[] { choices[1], choices[0], choices[2] };
|
||||
var program = source.Prepared.Emit(swapped);
|
||||
Assert.NotEqual(CuttingPlanStatus.Ready,
|
||||
CuttingPlanService.ReplayPrograms(snapshot, [source.Propose(program, Read(program), choices)], 0, default).Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Replay_PreservesFullAndExactConfiguredTabWithSubdivisions(bool tabbed)
|
||||
{
|
||||
var (part, settings) = Fixture();
|
||||
settings.TabsEnabled = tabbed;
|
||||
settings.TabConfig = new NormalTab { Size = 0.2 };
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
|
||||
var ready = CuttingPlanService.Plan(snapshot);
|
||||
Assert.True(ready.Status == CuttingPlanStatus.Ready, string.Join("; ", ready.Findings.Select(f => f.Message)));
|
||||
var source = snapshot.Placements[0];
|
||||
var proposal = ready.ProposedOrder[0];
|
||||
Assert.Equal(tabbed ? 39.8 : 40, proposal.Execution.Motions.Where(IsCut).Sum(m => m.Length), 8);
|
||||
var subdivided = Subdivide(proposal.CopyProgram());
|
||||
var replay = CuttingPlanService.ReplayPrograms(snapshot,
|
||||
[source.Propose(subdivided, Read(subdivided), proposal.ContourChoices)], 0, default);
|
||||
Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
|
||||
Assert.True(replay.IndependentlyReplayed);
|
||||
// A different retained gap is not the selected tab, even though it remains open.
|
||||
if (tabbed)
|
||||
{
|
||||
var counterfeit = proposal.CopyProgram();
|
||||
counterfeit.Mode = Mode.Absolute;
|
||||
var last = counterfeit.Codes.OfType<LinearMove>().Last(m => m.Layer == LayerType.Display);
|
||||
last.EndPoint = new Vector(0, 4.9);
|
||||
Assert.NotEqual(CuttingPlanStatus.Ready, CuttingPlanService.ReplayPrograms(snapshot,
|
||||
[source.Propose(counterfeit, Read(counterfeit), proposal.ContourChoices)], 0, default).Status);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedUnknownTabMetadata_IsRefusedButCompatibilityRouteRemainsUnchanged()
|
||||
{
|
||||
var (part, settings) = Fixture();
|
||||
Assert.True(part.RestoreLeadInProgram(Path(new Vector(0, 10)), true));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, CuttingPlanService.Plan(new CuttingPlanRequest([part], Start)).Status);
|
||||
settings.TabsEnabled = true;
|
||||
settings.TabConfig = new NormalTab { Size = 0.2 };
|
||||
var prepared = PreparedContours.Capture(part.BaseDrawing.Program, settings);
|
||||
var tabbed = prepared.Emit([prepared.ClosestEntry(0, Start)]);
|
||||
Assert.True(part.RestoreLeadInProgram(tabbed, true));
|
||||
Assert.NotEqual(CuttingPlanStatus.Ready, CuttingPlanService.Plan(new CuttingPlanRequest([part], Start, confirmedParameters: settings)).Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void CompleteFixedProgram_RetainsAuthoredEntryAndDimensions(bool locked)
|
||||
{
|
||||
var (part, settings) = Fixture();
|
||||
part.LeadInsLocked = locked;
|
||||
((LineLeadIn)settings.ExternalLeadIn).Length = 0.7;
|
||||
var before = ExplicitContourTests.Fingerprint(part.Program);
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], Start, confirmedParameters: settings,
|
||||
eligibleParts: locked ? null : []));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.Equal(before, ExplicitContourTests.Fingerprint(result.ProposedOrder[0].CopyProgram()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("circles")]
|
||||
[InlineData("mixed-circles")]
|
||||
[InlineData("arcs")]
|
||||
public void NativeCurves_SelectedRoundingClampingAndSubdivisionRemainValid(string shape)
|
||||
{
|
||||
var settings = ExplicitContourTests.Parameters();
|
||||
settings.RoundLeadInAngles = true;
|
||||
settings.LeadInAngleIncrement = 90;
|
||||
settings.ArcCircleLeadIn = new LineLeadIn { Length = 10 };
|
||||
var clean = PreparedContourTests.Holes();
|
||||
if (shape == "mixed-circles") clean.Codes.OfType<ArcMove>().Last().Rotation = RotationType.CW;
|
||||
if (shape == "arcs")
|
||||
{
|
||||
clean = ExplicitContourTests.Square(false);
|
||||
clean.MoveTo(6, 3);
|
||||
clean.ArcTo(4, 3, 5, 3, RotationType.CCW);
|
||||
clean.LineTo(6, 3);
|
||||
}
|
||||
var part = new Part(new Drawing("native", clean));
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
|
||||
var ready = CuttingPlanService.Plan(snapshot);
|
||||
Assert.True(ready.Status == CuttingPlanStatus.Ready, string.Join("; ", ready.Findings.Select(f => f.Message)));
|
||||
var source = snapshot.Placements[0];
|
||||
var selected = ready.ProposedOrder[0];
|
||||
if (shape != "arcs")
|
||||
{
|
||||
// Search may legitimately select cardinal candidates. Force off-angle
|
||||
// choices to exercise rounding, right hole before left for clear departure.
|
||||
var choices = new[] { source.Prepared.ClosestEntry(1, Start),
|
||||
source.Prepared.ClosestEntry(0, Start), source.Prepared.ClosestEntry(2, Start) };
|
||||
var explicitProgram = source.Prepared.Emit(choices);
|
||||
selected = source.Propose(explicitProgram, Read(explicitProgram), choices);
|
||||
Assert.Equal(CuttingPlanStatus.Ready,
|
||||
CuttingPlanService.ReplayPrograms(snapshot, [selected], 0, default).Status);
|
||||
var cuts = selected.Execution.Motions.Where(IsCut).Take(2).ToArray();
|
||||
Assert.Contains(Enumerable.Range(0, 2), i => cuts[i].Start!.Value.DistanceTo(selected.ContourChoices[i].Point) > 0.1);
|
||||
Assert.All(selected.Execution.Motions.Where(m => m.Layer == LayerType.Leadin).Take(2), m => Assert.True(m.Length < 2));
|
||||
}
|
||||
var program = Subdivide(selected.CopyProgram());
|
||||
Assert.Equal(CuttingPlanStatus.Ready, CuttingPlanService.ReplayPrograms(snapshot,
|
||||
[source.Propose(program, Read(program), selected.ContourChoices)], 0, default).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectedContract_IsFrozenBeforeReplayAndNeverTrustsCachedExecution()
|
||||
{
|
||||
var (part, settings) = Fixture();
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, confirmedParameters: settings));
|
||||
var source = snapshot.Placements[0];
|
||||
var choices = new[] { source.Prepared.ClosestEntry(0, Start) };
|
||||
var program = source.Prepared.Emit(choices);
|
||||
var cachedExecution = Read(program);
|
||||
// Supply stale complete execution alongside actual partial code. This must
|
||||
// refuse even when proposal storage defensively copies its input program.
|
||||
program.Mode = Mode.Absolute;
|
||||
program.Codes.RemoveRange(3, program.Codes.Count - 3);
|
||||
var proposal = source.Propose(program, cachedExecution, choices);
|
||||
((LineLeadIn)settings.ExternalLeadIn).Length = 900;
|
||||
part.BaseDrawing.Program.Codes.Clear();
|
||||
var replay = CuttingPlanService.ReplayPrograms(snapshot, [proposal], 0, default);
|
||||
Assert.NotEqual(CuttingPlanStatus.Ready, replay.Status);
|
||||
Assert.Empty(replay.ProposedOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedReindexedMergedNativeIntervals_PreserveCompleteCoverageAndPayload()
|
||||
{
|
||||
var settings = ExplicitContourTests.Parameters();
|
||||
var clean = ExplicitContourTests.Square(false);
|
||||
clean.Codes.Insert(1, new LinearMove(0, 7));
|
||||
var part = new Part(new Drawing("subdivided clean", clean));
|
||||
var program = new Program();
|
||||
program.MoveTo(5, 10.3);
|
||||
program.Codes.Add(new LinearMove(5, 10) { Layer = LayerType.Leadin });
|
||||
program.LineTo(10, 10); program.LineTo(10, 0); program.LineTo(0, 0);
|
||||
program.LineTo(0, 10); program.LineTo(5, 10);
|
||||
Assert.True(part.RestoreLeadInProgram(program, true));
|
||||
var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], new Vector(5, 12), confirmedParameters: settings));
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
Assert.Equal(ExplicitContourTests.Fingerprint(program), ExplicitContourTests.Fingerprint(result.ProposedOrder[0].CopyProgram()));
|
||||
}
|
||||
|
||||
private static (Part, CuttingParameters) Fixture()
|
||||
{
|
||||
var settings = ExplicitContourTests.Parameters();
|
||||
var clean = ExplicitContourTests.Square(false);
|
||||
var prepared = PreparedContours.Capture(clean, settings);
|
||||
var part = new Part(new Drawing("square", clean));
|
||||
Assert.True(part.RestoreLeadInProgram(prepared.Emit([prepared.ClosestEntry(0, Start)]), false));
|
||||
return (part, settings);
|
||||
}
|
||||
|
||||
private static Program Path(params Vector[] points)
|
||||
{
|
||||
var program = new Program();
|
||||
program.MoveTo(-0.3, 5);
|
||||
program.Codes.Add(new LinearMove(0, 5) { Layer = LayerType.Leadin });
|
||||
foreach (var point in points) program.LineTo(point);
|
||||
return program;
|
||||
}
|
||||
|
||||
private static OwnedExecution Read(Program program) => ExecutionMotionReader.Read(program, Vector.Zero, null, default);
|
||||
private static bool IsCut(ExecutionMotion motion) => !motion.Rapid && motion.Layer is LayerType.Cut or LayerType.Display;
|
||||
|
||||
private static Program Subdivide(Program program)
|
||||
{
|
||||
// Flatten executed frames through the existing native reader, not tessellation.
|
||||
var copy = new Program();
|
||||
foreach (var motion in Read(program).Motions)
|
||||
{
|
||||
if (motion.Rapid) { copy.MoveTo(motion.End); continue; }
|
||||
var midpoint = motion.Curve.Midpoint;
|
||||
if (motion.Curve.ToEntity() is Arc arc)
|
||||
{
|
||||
copy.Codes.Add(new ArcMove(midpoint, arc.Center, arc.Rotation) { Layer = motion.Layer });
|
||||
copy.Codes.Add(new ArcMove(motion.End, arc.Center, arc.Rotation) { Layer = motion.Layer });
|
||||
}
|
||||
else if (motion.Curve.ToEntity() is Circle circle)
|
||||
{
|
||||
var clockwise = PostVerificationGeometry.Curve.Create(motion.Start!.Value, motion.End, circle.Center, true);
|
||||
var original = motion.Curve.SameDirection(clockwise) ? RotationType.CW : RotationType.CCW;
|
||||
copy.Codes.Add(new ArcMove(midpoint, circle.Center, original) { Layer = motion.Layer });
|
||||
copy.Codes.Add(new ArcMove(motion.End, circle.Center, original) { Layer = motion.Layer });
|
||||
}
|
||||
else
|
||||
{
|
||||
copy.Codes.Add(new LinearMove(midpoint) { Layer = motion.Layer });
|
||||
copy.Codes.Add(new LinearMove(motion.End) { Layer = motion.Layer });
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -37,7 +37,8 @@ public class ReplayPoseBindingTests
|
||||
&& BitConverter.DoubleToInt64Bits(source.Rotation) == BitConverter.DoubleToInt64Bits(rotation);
|
||||
Assert.Equal(fault == "matching-bits", sameBits);
|
||||
var selected = new FixedProgramPlacement(source.SourcePart, source.SourceOrdinal, new Vector(x, y), rotation,
|
||||
source.LeadInsLocked, proposal.Execution, proposal.CopyProgram(), source.Prepared, source.Material, proposal.ContourChoices);
|
||||
source.LeadInsLocked, proposal.Execution, proposal.CopyProgram(), source.Prepared, source.Material, proposal.ContourChoices)
|
||||
.Propose(proposal.CopyProgram(), proposal.Execution, proposal.ContourChoices);
|
||||
var replay = CuttingPlanService.ReplayPrograms(snapshot, [selected], 0, default);
|
||||
Assert.Equal(sameBits ? CuttingPlanStatus.Ready : CuttingPlanStatus.InvalidInput, replay.Status);
|
||||
Assert.Equal(sameBits, replay.IndependentlyReplayed);
|
||||
|
||||
Reference in new issue
Block a user