refactor: use IDistanceComputer and rename Type to StrategyIndex
Wire IDistanceComputer into RotationSlideStrategy, replacing inline CPU/GPU branching. BestFitFinder constructs the appropriate implementation. Replace PushDirection enum with direction vectors in BuildOffsets. Rename IBestFitStrategy.Type and PairCandidate.StrategyType to StrategyIndex for clarity (JSON field name unchanged for backward compatibility). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,14 +12,16 @@ namespace OpenNest.Engine.BestFit
|
|||||||
public class BestFitFinder
|
public class BestFitFinder
|
||||||
{
|
{
|
||||||
private readonly IPairEvaluator _evaluator;
|
private readonly IPairEvaluator _evaluator;
|
||||||
private readonly ISlideComputer _slideComputer;
|
private readonly IDistanceComputer _distanceComputer;
|
||||||
private readonly BestFitFilter _filter;
|
private readonly BestFitFilter _filter;
|
||||||
|
|
||||||
public BestFitFinder(double maxPlateWidth, double maxPlateHeight,
|
public BestFitFinder(double maxPlateWidth, double maxPlateHeight,
|
||||||
IPairEvaluator evaluator = null, ISlideComputer slideComputer = null)
|
IPairEvaluator evaluator = null, ISlideComputer slideComputer = null)
|
||||||
{
|
{
|
||||||
_evaluator = evaluator ?? new PairEvaluator();
|
_evaluator = evaluator ?? new PairEvaluator();
|
||||||
_slideComputer = slideComputer;
|
_distanceComputer = slideComputer != null
|
||||||
|
? (IDistanceComputer)new GpuDistanceComputer(slideComputer)
|
||||||
|
: new CpuDistanceComputer();
|
||||||
var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) /
|
var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) /
|
||||||
System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
|
System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
|
||||||
_filter = new BestFitFilter
|
_filter = new BestFitFilter
|
||||||
@@ -79,12 +81,12 @@ namespace OpenNest.Engine.BestFit
|
|||||||
{
|
{
|
||||||
var angles = GetRotationAngles(drawing);
|
var angles = GetRotationAngles(drawing);
|
||||||
var strategies = new List<IBestFitStrategy>();
|
var strategies = new List<IBestFitStrategy>();
|
||||||
var type = 1;
|
var index = 1;
|
||||||
|
|
||||||
foreach (var angle in angles)
|
foreach (var angle in angles)
|
||||||
{
|
{
|
||||||
var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle));
|
var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle));
|
||||||
strategies.Add(new RotationSlideStrategy(angle, type++, desc, _slideComputer));
|
strategies.Add(new RotationSlideStrategy(angle, index++, desc, _distanceComputer));
|
||||||
}
|
}
|
||||||
|
|
||||||
return strategies;
|
return strategies;
|
||||||
@@ -226,7 +228,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
case BestFitSortField.ShortestSide:
|
case BestFitSortField.ShortestSide:
|
||||||
return results.OrderBy(r => r.ShortestSide).ToList();
|
return results.OrderBy(r => r.ShortestSide).ToList();
|
||||||
case BestFitSortField.Type:
|
case BestFitSortField.Type:
|
||||||
return results.OrderBy(r => r.Candidate.StrategyType)
|
return results.OrderBy(r => r.Candidate.StrategyIndex)
|
||||||
.ThenBy(r => r.Candidate.TestNumber).ToList();
|
.ThenBy(r => r.Candidate.TestNumber).ToList();
|
||||||
case BestFitSortField.OriginalSequence:
|
case BestFitSortField.OriginalSequence:
|
||||||
return results.OrderBy(r => r.Candidate.TestNumber).ToList();
|
return results.OrderBy(r => r.Candidate.TestNumber).ToList();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
{
|
{
|
||||||
public interface IBestFitStrategy
|
public interface IBestFitStrategy
|
||||||
{
|
{
|
||||||
int Type { get; }
|
int StrategyIndex { get; }
|
||||||
string Description { get; }
|
string Description { get; }
|
||||||
List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize);
|
List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ namespace OpenNest.Engine.BestFit
|
|||||||
Polygon stationaryPerimeter, Polygon stationaryHull, Vector correction)
|
Polygon stationaryPerimeter, Polygon stationaryHull, Vector correction)
|
||||||
{
|
{
|
||||||
_part2Rotation = part2Rotation;
|
_part2Rotation = part2Rotation;
|
||||||
Type = type;
|
StrategyIndex = type;
|
||||||
Description = description;
|
Description = description;
|
||||||
_stationaryPerimeter = stationaryPerimeter;
|
_stationaryPerimeter = stationaryPerimeter;
|
||||||
_stationaryHull = stationaryHull;
|
_stationaryHull = stationaryHull;
|
||||||
_correction = correction;
|
_correction = correction;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int Type { get; }
|
public int StrategyIndex { get; }
|
||||||
public string Description { get; }
|
public string Description { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -155,7 +155,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
Part1Rotation = 0,
|
Part1Rotation = 0,
|
||||||
Part2Rotation = _part2Rotation,
|
Part2Rotation = _part2Rotation,
|
||||||
Part2Offset = offset,
|
Part2Offset = offset,
|
||||||
StrategyType = Type,
|
StrategyIndex = StrategyIndex,
|
||||||
TestNumber = testNumber,
|
TestNumber = testNumber,
|
||||||
Spacing = spacing
|
Spacing = spacing
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
public double Part1Rotation { get; set; }
|
public double Part1Rotation { get; set; }
|
||||||
public double Part2Rotation { get; set; }
|
public double Part2Rotation { get; set; }
|
||||||
public Vector Part2Offset { get; set; }
|
public Vector Part2Offset { get; set; }
|
||||||
public int StrategyType { get; set; }
|
public int StrategyIndex { get; set; }
|
||||||
public int TestNumber { get; set; }
|
public int TestNumber { get; set; }
|
||||||
public double Spacing { get; set; }
|
public double Spacing { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,31 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace OpenNest.Engine.BestFit
|
namespace OpenNest.Engine.BestFit
|
||||||
{
|
{
|
||||||
public class RotationSlideStrategy : IBestFitStrategy
|
public class RotationSlideStrategy : IBestFitStrategy
|
||||||
{
|
{
|
||||||
private readonly ISlideComputer _slideComputer;
|
private readonly IDistanceComputer _distanceComputer;
|
||||||
|
|
||||||
private static readonly PushDirection[] AllDirections =
|
private static readonly (double DirX, double DirY)[] PushDirections =
|
||||||
{
|
{
|
||||||
PushDirection.Left, PushDirection.Down, PushDirection.Right, PushDirection.Up
|
(-1, 0), // Left
|
||||||
|
(0, -1), // Down
|
||||||
|
(1, 0), // Right
|
||||||
|
(0, 1) // Up
|
||||||
};
|
};
|
||||||
|
|
||||||
public RotationSlideStrategy(double part2Rotation, int type, string description,
|
public RotationSlideStrategy(double part2Rotation, int strategyIndex, string description,
|
||||||
ISlideComputer slideComputer = null)
|
IDistanceComputer distanceComputer)
|
||||||
{
|
{
|
||||||
Part2Rotation = part2Rotation;
|
Part2Rotation = part2Rotation;
|
||||||
Type = type;
|
StrategyIndex = strategyIndex;
|
||||||
Description = description;
|
Description = description;
|
||||||
_slideComputer = slideComputer;
|
_distanceComputer = distanceComputer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public double Part2Rotation { get; }
|
public double Part2Rotation { get; }
|
||||||
public int Type { get; }
|
public int StrategyIndex { get; }
|
||||||
public string Description { get; }
|
public string Description { get; }
|
||||||
|
|
||||||
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
|
public List<PairCandidate> GenerateCandidates(Drawing drawing, double spacing, double stepSize)
|
||||||
@@ -40,36 +42,25 @@ namespace OpenNest.Engine.BestFit
|
|||||||
var bbox1 = part1.BoundingBox;
|
var bbox1 = part1.BoundingBox;
|
||||||
var bbox2 = part2Template.BoundingBox;
|
var bbox2 = part2Template.BoundingBox;
|
||||||
|
|
||||||
// Collect offsets and directions across all 4 axes
|
var offsets = BuildOffsets(bbox1, bbox2, spacing, stepSize);
|
||||||
var allDx = new List<double>();
|
|
||||||
var allDy = new List<double>();
|
|
||||||
var allDirs = new List<PushDirection>();
|
|
||||||
|
|
||||||
foreach (var pushDir in AllDirections)
|
if (offsets.Length == 0)
|
||||||
BuildOffsets(bbox1, bbox2, spacing, stepSize, pushDir, allDx, allDy, allDirs);
|
|
||||||
|
|
||||||
if (allDx.Count == 0)
|
|
||||||
return candidates;
|
return candidates;
|
||||||
|
|
||||||
// Compute all distances — single GPU dispatch or CPU loop
|
var distances = _distanceComputer.ComputeDistances(
|
||||||
var distances = ComputeAllDistances(
|
part1Lines, part2TemplateLines, offsets);
|
||||||
part1Lines, part2TemplateLines, allDx, allDy, allDirs);
|
|
||||||
|
|
||||||
// Create candidates from valid results
|
|
||||||
var testNumber = 0;
|
var testNumber = 0;
|
||||||
|
|
||||||
for (var i = 0; i < allDx.Count; i++)
|
for (var i = 0; i < offsets.Length; i++)
|
||||||
{
|
{
|
||||||
var slideDist = distances[i];
|
var slideDist = distances[i];
|
||||||
if (slideDist >= double.MaxValue || slideDist < 0)
|
if (slideDist >= double.MaxValue || slideDist < 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var dx = allDx[i];
|
|
||||||
var dy = allDy[i];
|
|
||||||
var pushVector = GetPushVector(allDirs[i], slideDist);
|
|
||||||
var finalPosition = new Vector(
|
var finalPosition = new Vector(
|
||||||
part2Template.Location.X + dx + pushVector.X,
|
part2Template.Location.X + offsets[i].Dx + offsets[i].DirX * slideDist,
|
||||||
part2Template.Location.Y + dy + pushVector.Y);
|
part2Template.Location.Y + offsets[i].Dy + offsets[i].DirY * slideDist);
|
||||||
|
|
||||||
candidates.Add(new PairCandidate
|
candidates.Add(new PairCandidate
|
||||||
{
|
{
|
||||||
@@ -77,7 +68,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
Part1Rotation = 0,
|
Part1Rotation = 0,
|
||||||
Part2Rotation = Part2Rotation,
|
Part2Rotation = Part2Rotation,
|
||||||
Part2Offset = finalPosition,
|
Part2Offset = finalPosition,
|
||||||
StrategyType = Type,
|
StrategyIndex = StrategyIndex,
|
||||||
TestNumber = testNumber++,
|
TestNumber = testNumber++,
|
||||||
Spacing = spacing
|
Spacing = spacing
|
||||||
});
|
});
|
||||||
@@ -86,158 +77,44 @@ namespace OpenNest.Engine.BestFit
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void BuildOffsets(
|
private static SlideOffset[] BuildOffsets(Box bbox1, Box bbox2, double spacing, double stepSize)
|
||||||
Box bbox1, Box bbox2, double spacing, double stepSize,
|
|
||||||
PushDirection pushDir, List<double> allDx, List<double> allDy,
|
|
||||||
List<PushDirection> allDirs)
|
|
||||||
{
|
{
|
||||||
var isHorizontalPush = pushDir == PushDirection.Left || pushDir == PushDirection.Right;
|
var offsets = new List<SlideOffset>();
|
||||||
|
|
||||||
double perpMin, perpMax, pushStartOffset;
|
foreach (var (dirX, dirY) in PushDirections)
|
||||||
|
|
||||||
if (isHorizontalPush)
|
|
||||||
{
|
{
|
||||||
perpMin = -(bbox2.Length + spacing);
|
var isHorizontalPush = System.Math.Abs(dirX) > System.Math.Abs(dirY);
|
||||||
perpMax = bbox1.Length + bbox2.Length + spacing;
|
|
||||||
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
perpMin = -(bbox2.Width + spacing);
|
|
||||||
perpMax = bbox1.Width + bbox2.Width + spacing;
|
|
||||||
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
double perpMin, perpMax, pushStartOffset;
|
||||||
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
|
|
||||||
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
|
|
||||||
|
|
||||||
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
|
if (isHorizontalPush)
|
||||||
{
|
|
||||||
allDx.Add(isHorizontalPush ? startPos : offset);
|
|
||||||
allDy.Add(isHorizontalPush ? offset : startPos);
|
|
||||||
allDirs.Add(pushDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private double[] ComputeAllDistances(
|
|
||||||
List<Line> part1Lines, List<Line> part2TemplateLines,
|
|
||||||
List<double> allDx, List<double> allDy, List<PushDirection> allDirs)
|
|
||||||
{
|
|
||||||
var count = allDx.Count;
|
|
||||||
|
|
||||||
if (_slideComputer != null)
|
|
||||||
{
|
|
||||||
var stationarySegments = SpatialQuery.FlattenLines(part1Lines);
|
|
||||||
var movingSegments = SpatialQuery.FlattenLines(part2TemplateLines);
|
|
||||||
var offsets = new double[count * 2];
|
|
||||||
var directions = new int[count];
|
|
||||||
|
|
||||||
for (var i = 0; i < count; i++)
|
|
||||||
{
|
{
|
||||||
offsets[i * 2] = allDx[i];
|
perpMin = -(bbox2.Length + spacing);
|
||||||
offsets[i * 2 + 1] = allDy[i];
|
perpMax = bbox1.Length + bbox2.Length + spacing;
|
||||||
directions[i] = (int)allDirs[i];
|
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _slideComputer.ComputeBatchMultiDir(
|
|
||||||
stationarySegments, part1Lines.Count,
|
|
||||||
movingSegments, part2TemplateLines.Count,
|
|
||||||
offsets, count, directions);
|
|
||||||
}
|
|
||||||
|
|
||||||
var results = new double[count];
|
|
||||||
|
|
||||||
// Pre-calculate moving vertices in local space.
|
|
||||||
var movingVerticesLocal = new HashSet<Vector>();
|
|
||||||
for (var i = 0; i < part2TemplateLines.Count; i++)
|
|
||||||
{
|
|
||||||
movingVerticesLocal.Add(part2TemplateLines[i].StartPoint);
|
|
||||||
movingVerticesLocal.Add(part2TemplateLines[i].EndPoint);
|
|
||||||
}
|
|
||||||
var movingVerticesArray = movingVerticesLocal.ToArray();
|
|
||||||
|
|
||||||
// Pre-calculate stationary vertices in local space.
|
|
||||||
var stationaryVerticesLocal = new HashSet<Vector>();
|
|
||||||
for (var i = 0; i < part1Lines.Count; i++)
|
|
||||||
{
|
|
||||||
stationaryVerticesLocal.Add(part1Lines[i].StartPoint);
|
|
||||||
stationaryVerticesLocal.Add(part1Lines[i].EndPoint);
|
|
||||||
}
|
|
||||||
var stationaryVerticesArray = stationaryVerticesLocal.ToArray();
|
|
||||||
|
|
||||||
// Pre-sort stationary and moving edges for all 4 directions.
|
|
||||||
var stationaryEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
|
|
||||||
var movingEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
|
|
||||||
|
|
||||||
foreach (var dir in AllDirections)
|
|
||||||
{
|
|
||||||
var sEdges = new (Vector start, Vector end)[part1Lines.Count];
|
|
||||||
for (var i = 0; i < part1Lines.Count; i++)
|
|
||||||
sEdges[i] = (part1Lines[i].StartPoint, part1Lines[i].EndPoint);
|
|
||||||
|
|
||||||
if (dir == PushDirection.Left || dir == PushDirection.Right)
|
|
||||||
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
|
||||||
else
|
else
|
||||||
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
|
||||||
stationaryEdgesByDir[dir] = sEdges;
|
|
||||||
|
|
||||||
var opposite = SpatialQuery.OppositeDirection(dir);
|
|
||||||
var mEdges = new (Vector start, Vector end)[part2TemplateLines.Count];
|
|
||||||
for (var i = 0; i < part2TemplateLines.Count; i++)
|
|
||||||
mEdges[i] = (part2TemplateLines[i].StartPoint, part2TemplateLines[i].EndPoint);
|
|
||||||
|
|
||||||
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
|
|
||||||
mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
|
||||||
else
|
|
||||||
mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
|
||||||
movingEdgesByDir[dir] = mEdges;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use Parallel.For for the heavy lifting.
|
|
||||||
System.Threading.Tasks.Parallel.For(0, count, i =>
|
|
||||||
{
|
|
||||||
var dx = allDx[i];
|
|
||||||
var dy = allDy[i];
|
|
||||||
var dir = allDirs[i];
|
|
||||||
var movingOffset = new Vector(dx, dy);
|
|
||||||
|
|
||||||
var sEdges = stationaryEdgesByDir[dir];
|
|
||||||
var mEdges = movingEdgesByDir[dir];
|
|
||||||
var opposite = SpatialQuery.OppositeDirection(dir);
|
|
||||||
|
|
||||||
var minDist = double.MaxValue;
|
|
||||||
|
|
||||||
// Case 1: Moving vertices -> Stationary edges
|
|
||||||
foreach (var mv in movingVerticesArray)
|
|
||||||
{
|
{
|
||||||
var d = SpatialQuery.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir);
|
perpMin = -(bbox2.Width + spacing);
|
||||||
if (d < minDist) minDist = d;
|
perpMax = bbox1.Width + bbox2.Width + spacing;
|
||||||
|
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 2: Stationary vertices -> Moving edges (translated)
|
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
||||||
foreach (var sv in stationaryVerticesArray)
|
|
||||||
|
// Start on the opposite side of the push direction.
|
||||||
|
var pushComponent = isHorizontalPush ? dirX : dirY;
|
||||||
|
var startPos = pushComponent < 0 ? pushStartOffset : -pushStartOffset;
|
||||||
|
|
||||||
|
for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
|
||||||
{
|
{
|
||||||
var d = SpatialQuery.OneWayDistance(sv, mEdges, movingOffset, opposite);
|
var dx = isHorizontalPush ? startPos : offset;
|
||||||
if (d < minDist) minDist = d;
|
var dy = isHorizontalPush ? offset : startPos;
|
||||||
|
offsets.Add(new SlideOffset(dx, dy, dirX, dirY));
|
||||||
}
|
}
|
||||||
|
|
||||||
results[i] = minDist;
|
|
||||||
});
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Vector GetPushVector(PushDirection direction, double distance)
|
|
||||||
{
|
|
||||||
switch (direction)
|
|
||||||
{
|
|
||||||
case PushDirection.Left: return new Vector(-distance, 0);
|
|
||||||
case PushDirection.Right: return new Vector(distance, 0);
|
|
||||||
case PushDirection.Down: return new Vector(0, -distance);
|
|
||||||
case PushDirection.Up: return new Vector(0, distance);
|
|
||||||
default: return Vector.Zero;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return offsets.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ namespace OpenNest.IO
|
|||||||
Part1Rotation = r.Part1Rotation,
|
Part1Rotation = r.Part1Rotation,
|
||||||
Part2Rotation = r.Part2Rotation,
|
Part2Rotation = r.Part2Rotation,
|
||||||
Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY),
|
Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY),
|
||||||
StrategyType = r.StrategyType,
|
StrategyIndex = r.StrategyType,
|
||||||
TestNumber = r.TestNumber,
|
TestNumber = r.TestNumber,
|
||||||
Spacing = r.CandidateSpacing
|
Spacing = r.CandidateSpacing
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ namespace OpenNest.IO
|
|||||||
Part2Rotation = r.Candidate.Part2Rotation,
|
Part2Rotation = r.Candidate.Part2Rotation,
|
||||||
Part2OffsetX = r.Candidate.Part2Offset.X,
|
Part2OffsetX = r.Candidate.Part2Offset.X,
|
||||||
Part2OffsetY = r.Candidate.Part2Offset.Y,
|
Part2OffsetY = r.Candidate.Part2Offset.Y,
|
||||||
StrategyType = r.Candidate.StrategyType,
|
StrategyType = r.Candidate.StrategyIndex,
|
||||||
TestNumber = r.Candidate.TestNumber,
|
TestNumber = r.Candidate.TestNumber,
|
||||||
CandidateSpacing = r.Candidate.Spacing,
|
CandidateSpacing = r.Candidate.Spacing,
|
||||||
RotatedArea = r.RotatedArea,
|
RotatedArea = r.RotatedArea,
|
||||||
|
|||||||
Reference in New Issue
Block a user