mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-05 20:02:11 -04:00
fix(cutting): retain authored motion metadata in owned programs
This commit is contained in:
1 parent
e00aa113a0
commit
fb7ef6fd0d
4 files changed
+362
-51
No files matched your search
@@ -93,9 +93,9 @@ public sealed class FixedProgramPlacement
|
||||
public IReadOnlyList<ContourChoice> ContourChoices { get; }
|
||||
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 : (Program)program.Clone();
|
||||
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, proposed, Prepared, Material, choices);
|
||||
new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution, OwnedProgramCopy.Copy(proposed), Prepared, Material, choices);
|
||||
|
||||
public Part SourcePart { get; }
|
||||
public int SourceOrdinal { get; }
|
||||
|
||||
@@ -48,20 +48,20 @@ public static class CuttingPlanService
|
||||
throw new NotSupportedException("Cutoff dependency ordering is outside the fixed-program route slice.");
|
||||
// Validate both original graphs before Clone or any virtual transform can
|
||||
// erase unsupported runtime semantics, including fixed/ineligible targets.
|
||||
ValidateCloneGraph(source.BaseDrawing.Program, token);
|
||||
ValidateCloneGraph(source.Program, token);
|
||||
OwnedProgramCopy.Validate(source.BaseDrawing.Program, token);
|
||||
OwnedProgramCopy.Validate(source.Program, token);
|
||||
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
|
||||
if (!clean.HasCuttingContour)
|
||||
throw new NotSupportedException("Scribe-only or noncutting source drawings are outside this route slice.");
|
||||
var execution = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
|
||||
if (!execution.HasCuttingContour)
|
||||
throw new ArgumentException("Placed program has no nonzero cutting contour motions.");
|
||||
var ownedProgram = (Program)source.Program.Clone();
|
||||
var ownedProgram = OwnedProgramCopy.Copy(source.Program, token);
|
||||
PreparedContours prepared = null;
|
||||
LeadMaterialSnapshot material = null;
|
||||
if (request.ConfirmedParameters != null)
|
||||
{
|
||||
var ownedClean = (Program)source.BaseDrawing.Program.Clone();
|
||||
var ownedClean = OwnedProgramCopy.Copy(source.BaseDrawing.Program, token);
|
||||
ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation);
|
||||
material = LeadMaterialSnapshot.Capture(ownedClean, source.Location, token);
|
||||
if (!material.IsComplete)
|
||||
@@ -272,51 +272,6 @@ public static class CuttingPlanService
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateCloneGraph(Program program, CancellationToken token)
|
||||
{
|
||||
var active = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
var done = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
var budget = 1000000;
|
||||
Visit(program);
|
||||
void Visit(Program current)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (current == null || active.Contains(current) || active.Count >= 64)
|
||||
throw new ArgumentException("Missing, recursive or excessively nested clone graph.");
|
||||
if (done.Contains(current)) return;
|
||||
// Inactive registered subprograms may be motionless, but Clone still
|
||||
// traverses them. Refuse unknown semantics without invoking virtual Clone.
|
||||
if (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode))
|
||||
throw new NotSupportedException("Unsupported program runtime type or mode.");
|
||||
if (current.Codes == null)
|
||||
throw new ArgumentException("Missing clone graph instructions.");
|
||||
active.Add(current);
|
||||
foreach (var code in current.Codes)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new ArgumentException("Clone graph exceeds the verification limit.");
|
||||
if (code == null)
|
||||
throw new ArgumentException("Missing clone graph instruction.");
|
||||
var type = code.GetType();
|
||||
if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove)
|
||||
&& type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf))
|
||||
throw new NotSupportedException("Unsupported instruction runtime type.");
|
||||
if (code is SubProgramCall call)
|
||||
Visit(call.Program);
|
||||
}
|
||||
foreach (var child in current.SubPrograms.Values)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new ArgumentException("Clone graph exceeds the verification limit.");
|
||||
Visit(child);
|
||||
}
|
||||
active.Remove(current);
|
||||
done.Add(current);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<CuttingPlanFinding> Map(CuttingPlanSnapshot snapshot,
|
||||
IEnumerable<PostVerificationFinding> findings) => findings.Select(finding =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>Lossless owned copies at the cutting-plan boundary, not a general Clone change.</summary>
|
||||
internal static class OwnedProgramCopy
|
||||
{
|
||||
private const int Limit = 1000000;
|
||||
private const int MaxDepth = 64;
|
||||
|
||||
internal static Program Copy(Program original, CancellationToken token = default)
|
||||
{
|
||||
Validate(original, token);
|
||||
// Built-in Clone preserves mode/rotation and binds calls without rotating setters.
|
||||
// Its per-parent maps can duplicate diamonds; restore one global owned graph below.
|
||||
var cloned = (Program)original.Clone();
|
||||
var programs = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
|
||||
var codes = new Dictionary<ICode, ICode>(ReferenceEqualityComparer.Instance);
|
||||
var bindings = new Dictionary<Dictionary<string, string>, Dictionary<string, string>>(ReferenceEqualityComparer.Instance);
|
||||
return Restore(original, cloned);
|
||||
|
||||
Program Restore(Program source, Program copy)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (programs.TryGetValue(source, out var existing)) return existing;
|
||||
programs.Add(source, copy);
|
||||
for (var i = 0; i < source.Codes.Count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var authored = source.Codes[i];
|
||||
if (codes.TryGetValue(authored, out var owned))
|
||||
{
|
||||
copy.Codes[i] = owned;
|
||||
continue;
|
||||
}
|
||||
owned = copy.Codes[i];
|
||||
codes.Add(authored, owned);
|
||||
if (authored is Motion motion)
|
||||
{
|
||||
var target = (Motion)owned;
|
||||
// All other built-in fields are retained by their Clone implementations.
|
||||
target.UseExactStop = motion.UseExactStop;
|
||||
target.Feedrate = motion.Feedrate;
|
||||
if (motion.VariableRefs != null)
|
||||
{
|
||||
if (!bindings.TryGetValue(motion.VariableRefs, out var refs))
|
||||
{
|
||||
refs = new Dictionary<string, string>(motion.VariableRefs, motion.VariableRefs.Comparer);
|
||||
bindings.Add(motion.VariableRefs, refs);
|
||||
}
|
||||
target.VariableRefs = refs;
|
||||
}
|
||||
}
|
||||
if (authored is SubProgramCall call)
|
||||
{
|
||||
var target = (SubProgramCall)owned;
|
||||
target.BindProgram(Restore(call.Program, target.Program));
|
||||
}
|
||||
}
|
||||
foreach (var (id, child) in source.SubPrograms)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
copy.SubPrograms[id] = Restore(child, copy.SubPrograms[id]);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
// Unlike the execution reader, inactive registered graphs can be motionless or
|
||||
// contain suppressed motions. They still must be supported, acyclic and bounded.
|
||||
internal static void Validate(Program program, CancellationToken token)
|
||||
{
|
||||
var active = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
var done = new Dictionary<Program, (int Cost, int Depth)>(ReferenceEqualityComparer.Instance);
|
||||
var budget = Limit;
|
||||
Visit(program);
|
||||
(int Cost, int Depth) Visit(Program current)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (current == null || active.Contains(current) || active.Count >= MaxDepth)
|
||||
throw new ArgumentException("Missing, recursive or excessively nested clone graph.");
|
||||
if (done.TryGetValue(current, out var previous))
|
||||
{
|
||||
if (active.Count + previous.Depth > MaxDepth)
|
||||
throw new ArgumentException("Excessively nested clone graph.");
|
||||
return previous;
|
||||
}
|
||||
if (current.GetType() != typeof(Program) || !Enum.IsDefined(current.Mode))
|
||||
throw new NotSupportedException("Unsupported program runtime type or mode.");
|
||||
if (current.Codes == null)
|
||||
throw new ArgumentException("Missing clone graph instructions.");
|
||||
active.Add(current);
|
||||
var children = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
var cost = 1;
|
||||
var depth = 1;
|
||||
foreach (var code in current.Codes)
|
||||
{
|
||||
Step();
|
||||
cost++;
|
||||
if (code == null)
|
||||
throw new ArgumentException("Missing clone graph instruction.");
|
||||
var type = code.GetType();
|
||||
if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove)
|
||||
&& type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf))
|
||||
throw new NotSupportedException("Unsupported instruction runtime type.");
|
||||
if (code is SubProgramCall call) Child(call.Program);
|
||||
}
|
||||
foreach (var child in current.SubPrograms.Values)
|
||||
{
|
||||
Step();
|
||||
Child(child);
|
||||
}
|
||||
if (cost > Limit)
|
||||
throw new ArgumentException("Clone graph exceeds the verification limit.");
|
||||
active.Remove(current);
|
||||
var result = (cost, depth);
|
||||
done.Add(current, result);
|
||||
return result;
|
||||
|
||||
void Child(Program child)
|
||||
{
|
||||
var summary = Visit(child);
|
||||
// Program.Clone shares children only within one parent; bound its real
|
||||
// expanded work as well as the distinct original graph before calling it.
|
||||
if (!children.Add(child)) return;
|
||||
if (summary.Cost > Limit - cost)
|
||||
throw new ArgumentException("Clone expansion exceeds the verification limit.");
|
||||
cost += summary.Cost;
|
||||
depth = System.Math.Max(depth, summary.Depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
void Step()
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (--budget < 0)
|
||||
throw new ArgumentException("Clone graph exceeds the verification limit.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingPlanning;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
public class OwnedProgramCopyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(true, true)]
|
||||
public void FixedPayload_PreservesEveryAuthoredFieldAndGraphAlias(bool confirmed, bool locked)
|
||||
{
|
||||
var parameters = ExplicitContourTests.Parameters();
|
||||
var clean = LeadPathValidationTests.Rectangle(0, 0, 10, 10);
|
||||
var prepared = PreparedContours.Capture(clean, parameters);
|
||||
var emitted = prepared.Emit([prepared.ClosestEntry(0, new Vector(-2, 5))]);
|
||||
var part = new Part(new Drawing("metadata", clean));
|
||||
Assert.True(part.RestoreLeadInProgram(emitted, locked));
|
||||
part.Rotate(0.2);
|
||||
AddMetadata(part.Program);
|
||||
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], new Vector(-2, 5),
|
||||
confirmedParameters: confirmed ? parameters : null, eligibleParts: confirmed ? [] : null));
|
||||
var captured = Assert.Single(snapshot.Placements);
|
||||
AssertGraph(part.Program, captured.CopyProgram());
|
||||
var result = CuttingPlanService.Plan(snapshot);
|
||||
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||
var proposal = Assert.Single(result.ProposedOrder);
|
||||
AssertGraph(part.Program, proposal.CopyProgram());
|
||||
var detached = proposal.CopyProgram();
|
||||
var expectedFeed = ((Motion)part.Program.Codes.First(c => c is Motion)).Feedrate;
|
||||
((Motion)detached.Codes.First(c => c is Motion)).Feedrate = 999;
|
||||
detached.SubPrograms[-40].Codes.Clear();
|
||||
detached.Variables.Clear();
|
||||
AssertGraph(part.Program, proposal.CopyProgram());
|
||||
part.Program.Codes.Clear();
|
||||
part.Program.SubPrograms[-40].Codes.Clear();
|
||||
part.Program.Variables.Clear();
|
||||
var retained = proposal.CopyProgram();
|
||||
Assert.Equal(expectedFeed, ((Motion)retained.Codes.First(c => c is Motion)).Feedrate);
|
||||
Assert.NotEmpty(retained.SubPrograms[-40].Codes);
|
||||
Assert.NotEmpty(retained.Variables);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Propose_OwnsLosslessStorageRatherThanRetainingCallerProgram()
|
||||
{
|
||||
var part = new Part(new Drawing("proposal", LeadPathValidationTests.Rectangle(0, 0, 10, 10)));
|
||||
var captured = Assert.Single(CuttingPlanService.Capture(new CuttingPlanRequest([part])).Placements);
|
||||
var authored = captured.CopyProgram();
|
||||
AddMetadata(authored);
|
||||
var proposed = captured.Propose(authored, ExecutionMotionReader.ReadSupported(authored, Vector.Zero, null), []);
|
||||
AssertGraph(authored, proposed.CopyProgram());
|
||||
authored.Codes.Clear(); authored.SubPrograms[-40].Codes.Clear(); authored.Variables.Clear();
|
||||
var retained = proposed.CopyProgram();
|
||||
Assert.NotEmpty(retained.Codes);
|
||||
Assert.NotEmpty(retained.SubPrograms[-40].Codes);
|
||||
Assert.NotEmpty(retained.Variables);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("cycle")]
|
||||
[InlineData("depth")]
|
||||
[InlineData("cached-depth")]
|
||||
[InlineData("expansion")]
|
||||
public void OwnedCopy_RefusesUnsafeCloneTraversalBeforeInvokingClone(string kind)
|
||||
{
|
||||
var root = new Program();
|
||||
if (kind == "cycle") root.SubPrograms[-1] = root;
|
||||
if (kind is "depth" or "cached-depth")
|
||||
{
|
||||
var child = new Program();
|
||||
if (kind == "cached-depth") root.SubPrograms[-2] = child;
|
||||
var parent = child;
|
||||
for (var i = 0; i < 64; i++)
|
||||
{
|
||||
var next = new Program(); next.SubPrograms[-1] = parent; parent = next;
|
||||
}
|
||||
root.SubPrograms[-1] = parent;
|
||||
}
|
||||
if (kind == "expansion")
|
||||
{
|
||||
var child = new Program();
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
var left = new Program(); left.SubPrograms[-1] = child;
|
||||
var right = new Program(); right.SubPrograms[-1] = child;
|
||||
var parent = new Program(); parent.SubPrograms[-1] = left; parent.SubPrograms[-2] = right;
|
||||
child = parent;
|
||||
}
|
||||
root.SubPrograms[-1] = child;
|
||||
}
|
||||
Assert.Throws<ArgumentException>(() => OwnedProgramCopy.Copy(root));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OwnedCopy_HonorsCancellationBeforeClone()
|
||||
{
|
||||
Assert.Throws<OperationCanceledException>(() => OwnedProgramCopy.Copy(new Program(), new CancellationToken(true)));
|
||||
}
|
||||
|
||||
private static void AddMetadata(Program root)
|
||||
{
|
||||
var leaf = new Program();
|
||||
leaf.MoveTo(-1, 0);
|
||||
leaf.Codes.Add(new LinearMove(-2, 0) { Layer = LayerType.Scribe });
|
||||
leaf.Codes.Add(new ArcMove(-1, 1, -1, 0, RotationType.CW) { Layer = LayerType.Scribe });
|
||||
leaf.Rotate(0.37);
|
||||
leaf.Mode = Mode.Incremental;
|
||||
var degrees = 0.37 * 180 / System.Math.PI;
|
||||
var middle = new Program();
|
||||
middle.Codes.Add(new SubProgramCall(leaf, degrees) { Id = -7, Offset = new Vector(-1, 0) });
|
||||
middle.SubPrograms[-7] = leaf;
|
||||
root.Codes.Add(new SubProgramCall(middle, 0) { Id = -9, Offset = new Vector(-5, -5) });
|
||||
root.Codes.Add(new SubProgramCall(leaf, degrees) { Id = -7, Offset = new Vector(-10, -10) });
|
||||
root.SubPrograms[-9] = middle;
|
||||
root.SubPrograms[-7] = leaf;
|
||||
var inactive = new Program(Mode.Incremental);
|
||||
inactive.Codes.Add(new LinearMove(4, 5) { Suppressed = true, Layer = LayerType.Display });
|
||||
inactive.SubPrograms[-7] = leaf;
|
||||
root.SubPrograms[-40] = inactive;
|
||||
root.SubPrograms[-41] = new Program(); // Accepted motionless inactive registration.
|
||||
foreach (var p in new[] { root, middle, leaf, inactive })
|
||||
{
|
||||
p.Codes.Insert(0, new Comment("literal, : #value"));
|
||||
p.Codes.Insert(1, new Feedrate(123.5) { VariableRef = "speed" });
|
||||
p.Codes.Insert(2, new Kerf(KerfType.Right));
|
||||
p.Variables["value"] = new VariableDefinition("value", "2+3", 5, inline: true, global: true);
|
||||
foreach (var motion in p.Codes.OfType<Motion>())
|
||||
{
|
||||
motion.UseExactStop = true;
|
||||
motion.Feedrate = 123;
|
||||
motion.VariableRefs = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["X"] = "value" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertGraph(Program source, Program copy)
|
||||
{
|
||||
var mapped = new Dictionary<Program, Program>(ReferenceEqualityComparer.Instance);
|
||||
var reverse = new HashSet<Program>(ReferenceEqualityComparer.Instance);
|
||||
Visit(source, copy);
|
||||
void Visit(Program original, Program owned)
|
||||
{
|
||||
Assert.NotSame(original, owned);
|
||||
if (mapped.TryGetValue(original, out var previous))
|
||||
{
|
||||
Assert.Same(previous, owned);
|
||||
return;
|
||||
}
|
||||
Assert.True(reverse.Add(owned));
|
||||
mapped.Add(original, owned);
|
||||
Assert.Equal(original.Mode, owned.Mode);
|
||||
Bits(original.Rotation, owned.Rotation);
|
||||
Assert.NotSame(original.Codes, owned.Codes);
|
||||
Assert.NotSame(original.Variables, owned.Variables);
|
||||
Assert.NotSame(original.SubPrograms, owned.SubPrograms);
|
||||
Assert.Equal(original.Variables.Keys, owned.Variables.Keys);
|
||||
foreach (var (key, value) in original.Variables)
|
||||
{
|
||||
var actual = owned.Variables[key];
|
||||
Assert.Equal(value.Name, actual.Name); Assert.Equal(value.Expression, actual.Expression);
|
||||
Bits(value.Value, actual.Value); Assert.Equal(value.Inline, actual.Inline); Assert.Equal(value.Global, actual.Global);
|
||||
}
|
||||
Assert.Equal(original.Codes.Count, owned.Codes.Count);
|
||||
for (var i = 0; i < original.Codes.Count; i++)
|
||||
{
|
||||
var code = original.Codes[i]; var actual = owned.Codes[i];
|
||||
Assert.NotSame(code, actual); Assert.Equal(code.GetType(), actual.GetType());
|
||||
if (code is Motion motion)
|
||||
{
|
||||
var m = Assert.IsAssignableFrom<Motion>(actual);
|
||||
Point(motion.EndPoint, m.EndPoint);
|
||||
Assert.Equal(motion.UseExactStop, m.UseExactStop); Assert.Equal(motion.Feedrate, m.Feedrate);
|
||||
Assert.Equal(motion.Suppressed, m.Suppressed);
|
||||
if (motion.VariableRefs == null) Assert.Null(m.VariableRefs);
|
||||
else
|
||||
{
|
||||
Assert.NotSame(motion.VariableRefs, m.VariableRefs);
|
||||
Assert.Equal(motion.VariableRefs, m.VariableRefs);
|
||||
Assert.Equal(motion.VariableRefs.ContainsKey("x"), m.VariableRefs!.ContainsKey("x"));
|
||||
}
|
||||
if (motion is LinearMove line) Assert.Equal(line.Layer, ((LinearMove)m).Layer);
|
||||
if (motion is ArcMove arc)
|
||||
{
|
||||
var a = Assert.IsType<ArcMove>(m);
|
||||
Point(arc.CenterPoint, a.CenterPoint); Assert.Equal(arc.Rotation, a.Rotation); Assert.Equal(arc.Layer, a.Layer);
|
||||
}
|
||||
}
|
||||
if (code is Comment comment) Assert.Equal(comment.Value, Assert.IsType<Comment>(actual).Value);
|
||||
if (code is Feedrate feed)
|
||||
{
|
||||
var f = Assert.IsType<Feedrate>(actual); Bits(feed.Value, f.Value); Assert.Equal(feed.VariableRef, f.VariableRef);
|
||||
}
|
||||
if (code is Kerf kerf) Assert.Equal(kerf.Value, Assert.IsType<Kerf>(actual).Value);
|
||||
if (code is SubProgramCall call)
|
||||
{
|
||||
var c = Assert.IsType<SubProgramCall>(actual);
|
||||
Assert.Equal(call.Id, c.Id); Point(call.Offset, c.Offset); Bits(call.Rotation, c.Rotation);
|
||||
Visit(call.Program, c.Program);
|
||||
}
|
||||
}
|
||||
Assert.Equal(original.SubPrograms.Keys, owned.SubPrograms.Keys);
|
||||
foreach (var (key, child) in original.SubPrograms) Visit(child, owned.SubPrograms[key]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Point(Vector expected, Vector actual) { Bits(expected.X, actual.X); Bits(expected.Y, actual.Y); }
|
||||
private static void Bits(double expected, double actual) => Assert.Equal(BitConverter.DoubleToInt64Bits(expected), BitConverter.DoubleToInt64Bits(actual));
|
||||
}
|
||||
Reference in new issue
Block a user