mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 07:34:14 -04:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69304d3851 | ||
|
|
1c6d02c480 | ||
|
|
d328c5f691 | ||
|
|
e897258a85 | ||
|
|
1b9d8a36d8 | ||
|
|
b750475228 | ||
|
|
138c86526a | ||
|
|
07e682a654 | ||
|
|
0b29c683b9 | ||
|
|
71bd964344 | ||
|
|
1d50c6ce60 | ||
|
|
962ae8c825 | ||
|
|
d3e325f36c | ||
|
|
575412a464 | ||
|
|
60fb009329 | ||
|
|
41737a4f4c | ||
|
|
f1d1df3f17 | ||
|
|
81404fc673 | ||
|
|
a5b6c8a00e | ||
|
|
2a6e5504cd | ||
|
|
3d5c73a353 | ||
|
|
8b94e49711 | ||
|
|
2623517219 | ||
|
|
eff148c24e | ||
|
|
648ffbca65 | ||
|
|
4d94273a00 | ||
|
|
028adee046 | ||
|
|
3ddfff242d | ||
|
|
5fc563bbca | ||
|
|
785a861ab9 | ||
|
|
986ba3dee1 | ||
|
|
a73a85ebc0 | ||
|
|
8628c69098 | ||
|
|
c1c4b18f17 | ||
|
|
7e887921ea | ||
|
|
f50705c491 | ||
|
|
81f3c006ab | ||
|
|
b83518feac | ||
|
|
1826e6d5b5 | ||
|
|
47140cc59e | ||
|
|
91aef95b1b | ||
|
|
9110cd62e0 | ||
|
|
8e8b1de25f | ||
|
|
1add37d3e0 | ||
|
|
71f41e23e4 | ||
|
|
b591528aa2 | ||
|
|
12206ed8fd |
No files matched your search
@@ -97,6 +97,11 @@ jobs:
|
|||||||
if ($LASTEXITCODE -ne 0) { $failed += $project }
|
if ($LASTEXITCODE -ne 0) { $failed += $project }
|
||||||
}
|
}
|
||||||
if ($failed.Count -gt 0) { throw "Failed: $($failed -join ', ')" }
|
if ($failed.Count -gt 0) { throw "Failed: $($failed -join ', ')" }
|
||||||
|
- name: Test acceptance evidence checker
|
||||||
|
run: python scripts/test_windows_acceptance.py -v
|
||||||
|
- name: Verify named Windows acceptance coverage
|
||||||
|
if: always()
|
||||||
|
run: python scripts/windows_acceptance.py --results TestResults
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
if: >-
|
if: >-
|
||||||
github.repository == 'ajisaacs/OpenNest' &&
|
github.repository == 'ajisaacs/OpenNest' &&
|
||||||
(github.event_name == 'release' ||
|
((github.event_name == 'release' && github.event.release.prerelease == false) ||
|
||||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'))
|
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
|
|||||||
@@ -49,6 +49,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
dotnet build OpenNest.sln -c Release
|
dotnet build OpenNest.sln -c Release
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||||
|
- name: Install pinned Poppler for PDF report tests
|
||||||
|
run: |
|
||||||
|
$zip = Join-Path $env:RUNNER_TEMP 'poppler-26.09.0.zip'
|
||||||
|
Invoke-WebRequest 'https://github.com/oschwartz10612/poppler-windows/releases/download/v26.09.0-0/Release-26.09.0-0.zip' -OutFile $zip
|
||||||
|
$actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
if ($actual -ne '7a6f256a0ddf7536182246a5733331bf4677cbcc34f4663774947ad34556c8d0') { throw 'Poppler archive hash mismatch.' }
|
||||||
|
$destination = Join-Path $env:RUNNER_TEMP 'poppler-release-tests'
|
||||||
|
Expand-Archive $zip -DestinationPath $destination
|
||||||
|
$bin = Join-Path $destination 'poppler-26.09.0/Library/bin'
|
||||||
|
$tool = Join-Path $bin 'pdftotext.exe'
|
||||||
|
if (-not (Test-Path $tool)) { throw 'Pinned pdftotext.exe is missing.' }
|
||||||
|
& $tool -v
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Pinned pdftotext.exe cannot start.' }
|
||||||
|
$bin >> $env:GITHUB_PATH
|
||||||
- name: Run all test projects on Windows
|
- name: Run all test projects on Windows
|
||||||
# A test silent for 5 minutes is killed and named in the log, with a
|
# A test silent for 5 minutes is killed and named in the log, with a
|
||||||
# mini dump in TestResults, instead of holding the runner until the
|
# mini dump in TestResults, instead of holding the runner until the
|
||||||
|
|||||||
@@ -106,8 +106,13 @@ internal static class PostVerificationGeometry
|
|||||||
Center = center;
|
Center = center;
|
||||||
Radius = radius;
|
Radius = radius;
|
||||||
Sweep = sweep;
|
Sweep = sweep;
|
||||||
|
nativeEntity = new Lazy<Entity>(ToEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Never expose this mutable native wrapper; its geometry is read-only after creation.
|
||||||
|
// Lazy preserves the old conversion point for unsupported curves and is safe for concurrent checks.
|
||||||
|
private readonly Lazy<Entity> nativeEntity;
|
||||||
|
|
||||||
internal Vector Start { get; }
|
internal Vector Start { get; }
|
||||||
internal Vector End { get; }
|
internal Vector End { get; }
|
||||||
private Vector? Center { get; }
|
private Vector? Center { get; }
|
||||||
@@ -170,12 +175,12 @@ internal static class PostVerificationGeometry
|
|||||||
: Start + (End - Start) * (distance / Length);
|
: Start + (End - Start) * (distance / Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon;
|
internal bool Contains(Vector point) => nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= Epsilon;
|
||||||
|
|
||||||
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
||||||
{
|
{
|
||||||
var entity = ToEntity();
|
var entity = nativeEntity.Value;
|
||||||
var candidate = other.ToEntity();
|
var candidate = other.nativeEntity.Value;
|
||||||
// Native arc filters discard NaN supporting-circle intersections. Inspect
|
// Native arc filters discard NaN supporting-circle intersections. Inspect
|
||||||
// both unfiltered queries first: roundoff can differ by operand direction.
|
// both unfiltered queries first: roundoff can differ by operand direction.
|
||||||
// Coincident supports have separate overlap handling below.
|
// Coincident supports have separate overlap handling below.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -32,13 +32,18 @@ namespace OpenNest
|
|||||||
Color.FromArgb(215, 130, 130), // Light Coral
|
Color.FromArgb(215, 130, 130), // Light Coral
|
||||||
};
|
};
|
||||||
|
|
||||||
public static Color GetNextColor()
|
public static bool UseGoldenAngleColors { get; set; }
|
||||||
|
|
||||||
|
public static Color GetPartColor(int index)
|
||||||
{
|
{
|
||||||
var color = PartColors[nextColorIndex % PartColors.Length];
|
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||||
nextColorIndex++;
|
return UseGoldenAngleColors
|
||||||
return color;
|
? PartColorPalette.GoldenAngle(index)
|
||||||
|
: PartColors[index % PartColors.Length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Color GetNextColor() => GetPartColor(unchecked(nextColorIndex++) & int.MaxValue);
|
||||||
|
|
||||||
public Drawing()
|
public Drawing()
|
||||||
: this(string.Empty, new Program()) { }
|
: this(string.Empty, new Program()) { }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Drawing colors: golden-angle hue walk (skipping the etch-green band)
|
||||||
|
/// cycled through eight saturation/lightness tiers for wide separation.
|
||||||
|
/// </summary>
|
||||||
|
public static class PartColorPalette
|
||||||
|
{
|
||||||
|
// (lightness, saturation) tiers cycled on an irrational stride so any
|
||||||
|
// window of neighboring parts spans clearly different tones.
|
||||||
|
private static readonly (double Lightness, double Saturation)[] Tiers =
|
||||||
|
{
|
||||||
|
(0.52, 0.68), (0.64, 0.46), (0.75, 0.54), (0.61, 0.87),
|
||||||
|
(0.79, 0.97), (0.43, 0.82), (0.65, 0.76), (0.48, 0.97),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Plastic-number stride: tier(i) never falls into a short repeating phase with the hue walk.
|
||||||
|
private const double TierStride = 0.7548776662466927;
|
||||||
|
|
||||||
|
// Hues land in [0,95) + [170,295) mapped onto the golden-angle cycle,
|
||||||
|
// keeping fills out of the bright-green etch band.
|
||||||
|
private const double HueSpan = 295.0;
|
||||||
|
private const double BandStart = 95.0;
|
||||||
|
private const double BandWidth = 75.0;
|
||||||
|
|
||||||
|
public static Color GoldenAngle(int index)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||||
|
var hue = index * 137.508 % HueSpan;
|
||||||
|
if (hue >= BandStart)
|
||||||
|
hue += BandWidth;
|
||||||
|
var (lightness, saturation) = Tiers[
|
||||||
|
unchecked((int)(Tiers.Length * (index * TierStride % 1.0)) % Tiers.Length)];
|
||||||
|
|
||||||
|
var q = lightness < 0.5
|
||||||
|
? lightness * (1 + saturation)
|
||||||
|
: lightness + saturation - lightness * saturation;
|
||||||
|
var p = 2 * lightness - q;
|
||||||
|
|
||||||
|
int Channel(double t)
|
||||||
|
{
|
||||||
|
t = (t % 1 + 1) % 1;
|
||||||
|
var value = t < 1 / 6.0 ? p + (q - p) * 6 * t
|
||||||
|
: t < 0.5 ? q
|
||||||
|
: t < 2 / 3.0 ? p + (q - p) * (2 / 3.0 - t) * 6
|
||||||
|
: p;
|
||||||
|
return (int)System.Math.Round(value * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
var h = hue / 360.0;
|
||||||
|
return Color.FromArgb(Channel(h + 1 / 3.0), Channel(h), Channel(h - 1 / 3.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest.Sequencing
|
||||||
|
{
|
||||||
|
/// <summary>Moves one existing part to a one-based cutting sequence position.</summary>
|
||||||
|
public static class ManualPartSequencing
|
||||||
|
{
|
||||||
|
public static bool Move(Plate plate, Part part, int sequenceNumber)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plate);
|
||||||
|
ArgumentNullException.ThrowIfNull(part);
|
||||||
|
var order = plate.Parts.ToList();
|
||||||
|
if (sequenceNumber < 1 || sequenceNumber > order.Count)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var oldIndex = order.FindIndex(p => ReferenceEquals(p, part));
|
||||||
|
if (oldIndex < 0)
|
||||||
|
return false;
|
||||||
|
if (oldIndex == sequenceNumber - 1)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
order.RemoveAt(oldIndex);
|
||||||
|
order.Insert(sequenceNumber - 1, part);
|
||||||
|
plate.Parts.Reorder(order);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,20 @@ namespace OpenNest.Data;
|
|||||||
/// Loads the details of the nest highlighted in the Database-mode nest browser. Moving
|
/// Loads the details of the nest highlighted in the Database-mode nest browser. Moving
|
||||||
/// the highlight starts a new load and cancels the previous one; a superseded load's
|
/// the highlight starts a new load and cancels the previous one; a superseded load's
|
||||||
/// result or failure is discarded, so details never belong to a nest that is no longer
|
/// result or failure is discarded, so details never belong to a nest that is no longer
|
||||||
/// highlighted. Call from one thread (the UI thread).
|
/// highlighted. Also holds a small LRU cache of recently shown or <see cref="Prefetch"/>ed
|
||||||
|
/// nests, so browsing back onto one, or onto one a caller warmed ahead of time, resolves
|
||||||
|
/// without a server round trip. Call from one thread (the UI thread).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NestDetailsSession : IDisposable
|
public sealed class NestDetailsSession : IDisposable
|
||||||
{
|
{
|
||||||
|
// Bounds memory from cached plate/drawing geometry; generous enough for a caller's
|
||||||
|
// prefetch window plus some scrollback before the oldest entries are evicted.
|
||||||
|
private const int CacheCapacity = 25;
|
||||||
|
|
||||||
private readonly Func<Guid, CancellationToken, Task<NestDetails>> _load;
|
private readonly Func<Guid, CancellationToken, Task<NestDetails>> _load;
|
||||||
|
private readonly Dictionary<Guid, NestDetails> _cache = new();
|
||||||
|
private readonly List<Guid> _cacheOrder = new();
|
||||||
|
private readonly Dictionary<Guid, CancellationTokenSource> _prefetches = new();
|
||||||
private CancellationTokenSource? _pending;
|
private CancellationTokenSource? _pending;
|
||||||
private int _generation;
|
private int _generation;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -49,7 +58,18 @@ public sealed class NestDetailsSession : IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var details = await _load(id, cancellation.Token);
|
NestDetails details;
|
||||||
|
if (_cache.TryGetValue(id, out var cached))
|
||||||
|
{
|
||||||
|
Touch(id);
|
||||||
|
details = cached;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
details = await _load(id, cancellation.Token);
|
||||||
|
Remember(id, details);
|
||||||
|
}
|
||||||
|
|
||||||
if (generation != _generation)
|
if (generation != _generation)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -70,7 +90,63 @@ public sealed class NestDetailsSession : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Forgets the current details and discards any load in flight (no nest highlighted).</summary>
|
/// <summary>
|
||||||
|
/// Starts loading one nest's details in the background so a later <see cref="LoadAsync"/>
|
||||||
|
/// for it resolves from the cache instead of the server. Skipped when the nest is already
|
||||||
|
/// cached, already prefetching, or is the loaded/loading nest. Failures are swallowed here;
|
||||||
|
/// an on-demand load surfaces the error if the nest is actually selected later.
|
||||||
|
/// </summary>
|
||||||
|
public void Prefetch(Guid id)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
if (id == NestId || _cache.ContainsKey(id) || _prefetches.ContainsKey(id))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var cancellation = new CancellationTokenSource();
|
||||||
|
_prefetches[id] = cancellation;
|
||||||
|
_ = PrefetchCoreAsync(id, cancellation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PrefetchCoreAsync(Guid id, CancellationTokenSource cancellation)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var details = await _load(id, cancellation.Token);
|
||||||
|
if (!cancellation.IsCancellationRequested)
|
||||||
|
Remember(id, details);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Silent: this prefetch only mattered if the nest is later selected, and
|
||||||
|
// LoadAsync's own on-demand fetch will surface the error then.
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_prefetches.GetValueOrDefault(id), cancellation))
|
||||||
|
_prefetches.Remove(id);
|
||||||
|
cancellation.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Remember(Guid id, NestDetails details)
|
||||||
|
{
|
||||||
|
_cache[id] = details;
|
||||||
|
Touch(id);
|
||||||
|
while (_cacheOrder.Count > CacheCapacity)
|
||||||
|
{
|
||||||
|
var oldest = _cacheOrder[0];
|
||||||
|
_cacheOrder.RemoveAt(0);
|
||||||
|
_cache.Remove(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Touch(Guid id)
|
||||||
|
{
|
||||||
|
_cacheOrder.Remove(id);
|
||||||
|
_cacheOrder.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forgets the current details and discards any load or prefetch in flight.</summary>
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
@@ -83,6 +159,8 @@ public sealed class NestDetailsSession : IDisposable
|
|||||||
Details = null;
|
Details = null;
|
||||||
IsLoading = false;
|
IsLoading = false;
|
||||||
pending?.Cancel();
|
pending?.Cancel();
|
||||||
|
foreach (var prefetch in _prefetches.Values)
|
||||||
|
prefetch.Cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
private CancellationTokenSource Supersede()
|
private CancellationTokenSource Supersede()
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ public sealed class NestStorageSettings
|
|||||||
public bool IsDatabaseMode =>
|
public bool IsDatabaseMode =>
|
||||||
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
|
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
|
||||||
|
|
||||||
|
/// <summary>Validate and normalize a nest server base URL without changing storage mode.</summary>
|
||||||
|
public static bool TryNormalizeServerUrl(string? url, out string normalized)
|
||||||
|
{
|
||||||
|
normalized = NormalizeUrl(url);
|
||||||
|
if (Uri.TryCreate(normalized, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
normalized = "";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>%APPDATA%\OpenNest\storage.json.</summary>
|
/// <summary>%APPDATA%\OpenNest\storage.json.</summary>
|
||||||
public static string DefaultPath => Path.Combine(
|
public static string DefaultPath => Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
using OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||||
|
|
||||||
|
public class CutoutNfpProposalTests
|
||||||
|
{
|
||||||
|
private const double Spacing = 0.25;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeInsertFitsRoundCutoutAndRemainsValidAfterFrameRotation()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1, RotationPolicy.Fixed(System.Math.PI / 2));
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(0));
|
||||||
|
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
|
||||||
|
Assert.NotNull(pose);
|
||||||
|
Assert.Equal(insert.Id, pose.PartId);
|
||||||
|
Assert.Equal(0, pose.InstanceIndex);
|
||||||
|
Assert.True(insert.Rotation.Allows(pose.Rotation));
|
||||||
|
AssertSquareInRoundHole(pose, 6, Spacing);
|
||||||
|
|
||||||
|
var localFrame = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
||||||
|
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), localFrame,
|
||||||
|
JobPartGeometry.Read(insert.Geometry), pose, Spacing));
|
||||||
|
var framePose = new NestJobPlacement(frame.Id, 0, 11, 11, System.Math.PI / 2);
|
||||||
|
var movedInsert = pose with
|
||||||
|
{
|
||||||
|
X = framePose.X - pose.Y,
|
||||||
|
Y = framePose.Y + pose.X,
|
||||||
|
Rotation = pose.Rotation + framePose.Rotation,
|
||||||
|
};
|
||||||
|
// This insert is fixed at zero for the local proposal; the rotated variant needs
|
||||||
|
// a correspondingly legal global policy before it can be handed to a job.
|
||||||
|
var rotatedInsert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(System.Math.PI / 2));
|
||||||
|
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), framePose,
|
||||||
|
JobPartGeometry.Read(insert.Geometry), movedInsert, Spacing));
|
||||||
|
AssertSquareInRoundHole(movedInsert, 6, Spacing, framePose.X, framePose.Y);
|
||||||
|
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
|
||||||
|
var job = JobBuilder.Job(new[] { frame, rotatedInsert }, new[] { stock });
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, stock, new[] { framePose, movedInsert }) },
|
||||||
|
new[] { new PartFulfillment(frame.Id, 1, 1, 0), new PartFulfillment(insert.Id, 1, 1, 0) },
|
||||||
|
new[] { new StockUsage(stock.Id, 1, null) });
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeInsertFitsSquareCutoutWithoutUsingFillLattice()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
program.MoveTo(5, 5);
|
||||||
|
program.LineTo(5, 15);
|
||||||
|
program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, 5);
|
||||||
|
program.LineTo(5, 5);
|
||||||
|
var frame = JobBuilder.Part("square-frame", program, 1);
|
||||||
|
var insert = JobBuilder.Rectangle("large", 9, 9, 1, RotationPolicy.Fixed(0));
|
||||||
|
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
|
||||||
|
Assert.NotNull(pose);
|
||||||
|
Assert.All(new[] { (0.0, 0.0), (9.0, 0.0), (9.0, 9.0), (0.0, 9.0) }, corner =>
|
||||||
|
{
|
||||||
|
Assert.InRange(pose.X + corner.Item1, 5 + Spacing, 15 - Spacing);
|
||||||
|
Assert.InRange(pose.Y + corner.Item2, 5 + Spacing, 15 - Spacing);
|
||||||
|
});
|
||||||
|
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry),
|
||||||
|
new NestJobPlacement(frame.Id, 0, 0, 0, 0), JobPartGeometry.Read(insert.Geometry), pose, Spacing));
|
||||||
|
Assert.Null(CutoutNfpProposal.Find(frame, 0, JobBuilder.Rectangle("oversized", 9.6, 9.6, 1),
|
||||||
|
Spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObliqueFixedInsertRotationIsUsedInInnerFit()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
program.MoveTo(5, 5);
|
||||||
|
program.LineTo(5, 15);
|
||||||
|
program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, 5);
|
||||||
|
program.LineTo(5, 5);
|
||||||
|
var frame = JobBuilder.Part("frame", program, 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 3, 2, 1, RotationPolicy.Fixed(System.Math.PI / 4));
|
||||||
|
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
|
||||||
|
Assert.NotNull(pose);
|
||||||
|
Assert.True(insert.Rotation.Allows(pose.Rotation));
|
||||||
|
foreach (var (x, y) in new[] { (0.0, 0.0), (3.0, 0.0), (3.0, 2.0), (0.0, 2.0) })
|
||||||
|
{
|
||||||
|
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
|
||||||
|
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
|
||||||
|
Assert.InRange(rx, 5 + Spacing, 15 - Spacing);
|
||||||
|
Assert.InRange(ry, 5 + Spacing, 15 - Spacing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(9.51)]
|
||||||
|
[InlineData(10.0)]
|
||||||
|
[InlineData(9.6)]
|
||||||
|
public void OversizedInsertIsNeverProposed(double side)
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", side, side, 1);
|
||||||
|
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, Spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnsupportedCoordinateScaleFailsClosed()
|
||||||
|
{
|
||||||
|
const double scale = 1e15;
|
||||||
|
var program = TestShapes.Polyline((-scale, -scale), (scale, -scale),
|
||||||
|
(scale, scale), (-scale, scale));
|
||||||
|
program.MoveTo(-scale / 2, -scale / 2);
|
||||||
|
program.LineTo(-scale / 2, scale / 2);
|
||||||
|
program.LineTo(scale / 2, scale / 2);
|
||||||
|
program.LineTo(scale / 2, -scale / 2);
|
||||||
|
program.LineTo(-scale / 2, -scale / 2);
|
||||||
|
Assert.Null(CutoutNfpProposal.Find(JobBuilder.Part("huge", program, 1), 0,
|
||||||
|
JobBuilder.Rectangle("insert", 6, 6, 1), Spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImpossibleHugeSpacingFailsBeforeAnyOffset()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
|
||||||
|
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, 1e12));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertSquareInRoundHole(NestJobPlacement pose, double side, double spacing,
|
||||||
|
double centerX = 0, double centerY = 0)
|
||||||
|
{
|
||||||
|
var sin = System.Math.Sin(pose.Rotation);
|
||||||
|
var cos = System.Math.Cos(pose.Rotation);
|
||||||
|
foreach (var (x, y) in new[] { (0.0, 0.0), (side, 0.0), (side, side), (0.0, side) })
|
||||||
|
{
|
||||||
|
var dx = pose.X + x * cos - y * sin - centerX;
|
||||||
|
var dy = pose.Y + x * sin + y * cos - centerY;
|
||||||
|
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) <= 5 - spacing + 0.00001,
|
||||||
|
$"corner ({dx}, {dy}) does not clear the circular wall");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationIsNotReportedAsNoFit()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
|
||||||
|
using var cancelled = new CancellationTokenSource();
|
||||||
|
cancelled.Cancel();
|
||||||
|
Assert.Throws<OperationCanceledException>(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing,
|
||||||
|
cancelled.Token));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||||
|
|
||||||
|
[CollectionDefinition("Cutout pipeline registry", DisableParallelization = true)]
|
||||||
|
public sealed class CutoutPipelineRegistryCollection { }
|
||||||
|
|
||||||
|
[Collection("Cutout pipeline registry")]
|
||||||
|
public class CutoutPipelinePreviewTests
|
||||||
|
{
|
||||||
|
private static NestPipelineRequest Request(string engine = "Irregular")
|
||||||
|
{
|
||||||
|
var frame = new Drawing("frame", TestShapes.Ring(20, 10));
|
||||||
|
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
|
||||||
|
return new NestPipelineRequest(engine,
|
||||||
|
new[] { new NestItem { Drawing = frame, Quantity = 1 },
|
||||||
|
new NestItem { Drawing = insert, Quantity = 1 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Irregular")]
|
||||||
|
[InlineData("Default")]
|
||||||
|
public void RegisteredEngineExpandsOnlyPhysicalOriginalDrawings(string engine)
|
||||||
|
{
|
||||||
|
var request = Request(engine);
|
||||||
|
var result = NestPipeline.RunCutoutPreview(request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.True(result.CanKeep);
|
||||||
|
var plate = Assert.Single(result.Plates);
|
||||||
|
Assert.Equal(2, plate.Parts.Count);
|
||||||
|
Assert.Same(request.Items[0].Drawing, plate.Parts[0].BaseDrawing);
|
||||||
|
Assert.Same(request.Items[1].Drawing, plate.Parts[1].BaseDrawing);
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates[0].Placements.Select(p => p.PartId));
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 1, 0), (f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
Assert.Equal(0, request.Items[0].Drawing.Quantity.Nested);
|
||||||
|
Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Stub(Func<NestJob, NestJobResult> solve) : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default) => solve(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestJobResult Result(NestJob job, params NestJobPlacement[] poses)
|
||||||
|
{
|
||||||
|
var placed = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
var complete = job.Parts.All(p => placed.GetValueOrDefault(p.Id) == p.Quantity);
|
||||||
|
return new NestJobResult(complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||||
|
complete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound,
|
||||||
|
poses.Length == 0 ? Array.Empty<NestJobPlateResult>() :
|
||||||
|
new[] { new NestJobPlateResult(0, job.Plates[0], poses) },
|
||||||
|
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity,
|
||||||
|
placed.GetValueOrDefault(p.Id), p.Quantity - placed.GetValueOrDefault(p.Id))),
|
||||||
|
new[] { new StockUsage(job.Plates[0].Id, poses.Length == 0 ? 0 : 1,
|
||||||
|
job.Plates[0].Quantity - (poses.Length == 0 ? 0 : 1)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleSolveReceivesOneProxyAndNoDuplicatedInsertDemand()
|
||||||
|
{
|
||||||
|
var called = 0;
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
called++;
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
Assert.StartsWith("__cutout-proxy-", proxy.Id);
|
||||||
|
Assert.True(proxy.Rotation.Allows(0));
|
||||||
|
Assert.False(proxy.Rotation.Allows(System.Math.PI / 2));
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.Equal(1, called);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(2, result.Plates.Single().Parts.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IncompleteEngineDoesNotBindReservedInsertEvenWithInvalidConsent()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job => Result(job)), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("did not complete"));
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 0, 1),
|
||||||
|
(f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested);
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForgedFulfillmentOrDuplicateIndexDoesNotBindComposite()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var id = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")).Id;
|
||||||
|
var pose = new NestJobPlacement(id, 1, 11, 11, 0);
|
||||||
|
return Result(job, pose);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("accounting"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } };
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
var residual = job.Parts.Single(p => p.Id == "part-1");
|
||||||
|
Assert.Equal(1, residual.Quantity);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
|
||||||
|
new NestJobPlacement(residual.Id, 0, 22, 1, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1", "part-1" },
|
||||||
|
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.Single().Placements
|
||||||
|
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
|
||||||
|
Assert.All(result.Plates.Single().Parts.Where(p => p.BaseDrawing == request.Items[1].Drawing),
|
||||||
|
p => Assert.Same(request.Items[1].Drawing, p.BaseDrawing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UntrustedStockOverdrawOrRotationCannotBindAComposite()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11,
|
||||||
|
System.Math.PI / 2));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("rotation constraint"));
|
||||||
|
|
||||||
|
var overdraw = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
|
||||||
|
NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, job.Plates[0], new[] {
|
||||||
|
new NestJobPlacement(job.Parts[0].Id, 0, 11, 11, 0) }),
|
||||||
|
new NestJobPlateResult(1, job.Plates[0], new[] {
|
||||||
|
new NestJobPlacement(job.Parts[0].Id, 1, 11, 11, 0) }) },
|
||||||
|
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>())), "stub", request);
|
||||||
|
Assert.False(overdraw.CanKeep);
|
||||||
|
Assert.Empty(overdraw.Plates);
|
||||||
|
Assert.Contains(overdraw.Violations, v => v.Contains("only 1 are available"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationBeforePreparationDoesNotCallEngine()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
var engine = new Stub(_ => throw new Exception("Must not call Solve"));
|
||||||
|
Assert.Throws<OperationCanceledException>(() =>
|
||||||
|
NestPipeline.RunCutoutPreview(engine, "stub", Request("stub"), token: cts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
|
||||||
|
new NestJobPlacement("part-1", 0, 11, 11, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("required spacing"));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertCannotCommit(NestPipelineResult result, NestPipelineRequest request)
|
||||||
|
{
|
||||||
|
var nest = new Nest();
|
||||||
|
foreach (var item in request.Items)
|
||||||
|
nest.Drawings.Add(item.Drawing);
|
||||||
|
var empty = nest.CreatePlate();
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
|
||||||
|
Assert.Same(empty, Assert.Single(nest.Plates));
|
||||||
|
Assert.Empty(empty.Parts);
|
||||||
|
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CollectProgress : IProgress<NestJobProgress>
|
||||||
|
{
|
||||||
|
public List<NestJobProgress> Events { get; } = new();
|
||||||
|
public void Report(NestJobProgress value) => Events.Add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EvaluationProgressSurvivesButUnverifiedCommitsNeverEscape()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var progress = new CollectProgress();
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new ProgressStub(), "stub", request, progress);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Single(progress.Events);
|
||||||
|
Assert.Equal(NestJobStage.EvaluatingCandidate, progress.Events[0].Stage);
|
||||||
|
Assert.Equal(0, progress.Events[0].CommittedPlates);
|
||||||
|
Assert.Equal(0, progress.Events[0].CommittedParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ProgressStub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
|
||||||
|
job.Plates[0].Id, 0, 1, 1));
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
|
||||||
|
job.Plates[0].Id, 0, 1, 1));
|
||||||
|
return Result(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReorderedButContiguousEngineIndicesAreAccepted()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[1].Quantity = 3;
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) } };
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
Assert.Equal(2, job.Parts.Single(p => p.Id == "part-1").Quantity);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
|
||||||
|
new NestJobPlacement("part-1", 1, 22, 1, 0),
|
||||||
|
new NestJobPlacement("part-1", 0, 22, 9, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { 0, 1, 2 }, result.Raw.Plates.Single().Placements
|
||||||
|
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public void ForgedFulfillmentOrStockMetadataCannotBind(bool fulfillment)
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
var good = Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
return new NestJobResult(good.Status, good.StopReason, good.Plates,
|
||||||
|
fulfillment ? new[] { new PartFulfillment(proxy.Id, 1, 0, 1) } : good.Fulfillment,
|
||||||
|
fulfillment ? good.StockUsage : new[] { new StockUsage("sheet", 0, 1) });
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("accounting"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
|
||||||
|
{
|
||||||
|
var initial = Request("stub");
|
||||||
|
var request = initial with
|
||||||
|
{
|
||||||
|
Items = new[] { initial.Items[0], initial.Items[1],
|
||||||
|
new NestItem { Drawing = initial.Items[1].Drawing, Quantity = 1 } },
|
||||||
|
Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) }
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
var residual = job.Parts.Where(p => p.Id != proxy.Id)
|
||||||
|
.Select((p, i) => new NestJobPlacement(p.Id, 0, 22, 1 + 5 * i, 0));
|
||||||
|
return Result(job, new[] { new NestJobPlacement(proxy.Id, 0, 11, 11, 0) }
|
||||||
|
.Concat(residual).ToArray());
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1", "part-2" },
|
||||||
|
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
Assert.Same(result.Plates.Single().Parts[1].BaseDrawing,
|
||||||
|
result.Plates.Single().Parts[2].BaseDrawing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RepeatedFrameReservationsExpandOnTwoPhysicalSheets()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[0].Quantity = 2;
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
request = request with
|
||||||
|
{
|
||||||
|
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
|
||||||
|
Options = new NestJobOptions(maxPlates: 2)
|
||||||
|
};
|
||||||
|
var calls = 0;
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
|
||||||
|
Assert.Equal(2, proxies.Length);
|
||||||
|
Assert.Equal(2, job.Parts.Count);
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
foreach (var proxy in proxies)
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(2, result.Raw.Plates.Count);
|
||||||
|
Assert.All(result.Raw.Plates, plate => Assert.Equal(new[] { "part-0", "part-1" },
|
||||||
|
plate.Placements.Select(p => p.PartId)));
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 2, 0),
|
||||||
|
(f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
|
||||||
|
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplacedFrameReleasesInsertDemandWithoutABindablePartialProposal()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[0].Quantity = 2;
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } };
|
||||||
|
var calls = 0;
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
var first = job.Parts.Single(p => p.Id.EndsWith("-0"));
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (first.Id, 11.0, 11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.NoPlacementFound);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Equal(NestJobStatus.Incomplete, result.Raw.Status);
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 1, 1),
|
||||||
|
(f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentFrameRequirementsShareScarceInsertsByIdentity()
|
||||||
|
{
|
||||||
|
var initial = Request("stub");
|
||||||
|
var frame2 = new Drawing("second-frame", TestShapes.Ring(18, 10));
|
||||||
|
initial.Items[1].Quantity = 2;
|
||||||
|
initial.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
var request = initial with
|
||||||
|
{
|
||||||
|
Items = new[] { initial.Items[0],
|
||||||
|
new NestItem { Drawing = frame2, Quantity = 1 }, initial.Items[1] },
|
||||||
|
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
|
||||||
|
Assert.Equal(2, proxies.Length);
|
||||||
|
Assert.Equal(2, job.Parts.Count);
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
foreach (var proxy in proxies)
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-2", "part-1", "part-2" },
|
||||||
|
result.Raw.Plates.SelectMany(p => p.Placements).Select(p => p.PartId));
|
||||||
|
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
|
||||||
|
.Where(p => p.PartId == "part-2").Select(p => p.InstanceIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedQuarterTurnComposesLocalInsertRotationAndTranslation()
|
||||||
|
{
|
||||||
|
var frame = TestShapes.Rectangle(20, 20);
|
||||||
|
frame.MoveTo(5, 5);
|
||||||
|
frame.LineTo(5, 15);
|
||||||
|
frame.LineTo(15, 15);
|
||||||
|
frame.LineTo(15, 5);
|
||||||
|
frame.LineTo(5, 5);
|
||||||
|
var quarter = System.Math.PI / 2;
|
||||||
|
var request = new NestPipelineRequest("stub", new[] {
|
||||||
|
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 1,
|
||||||
|
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter },
|
||||||
|
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1,
|
||||||
|
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
Assert.True(proxy.Rotation.Allows(quarter));
|
||||||
|
Assert.False(proxy.Rotation.Allows(0));
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 21, 1, quarter));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var poses = result.Raw.Plates.Single().Placements;
|
||||||
|
Assert.Equal(quarter, poses[0].Rotation, 8);
|
||||||
|
Assert.Equal(quarter, poses[1].Rotation, 8);
|
||||||
|
// Independent square-hole oracle, not the production clearance predicate.
|
||||||
|
var localX = poses[1].Y - poses[0].Y;
|
||||||
|
var localY = -(poses[1].X - poses[0].X);
|
||||||
|
foreach (var dx in new[] { 0.0, 3.0 })
|
||||||
|
foreach (var dy in new[] { 0.0, 3.0 })
|
||||||
|
{
|
||||||
|
Assert.InRange(localX + dx, 5.25, 14.75);
|
||||||
|
Assert.InRange(localY + dy, 5.25, 14.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HeterogeneousStockUsesConservativeSpacingAndOriginalQuadrant()
|
||||||
|
{
|
||||||
|
var request = Request("stub") with
|
||||||
|
{
|
||||||
|
Stock = new[] {
|
||||||
|
new NestPlateStock("tight", new Size(22, 22), 1, 0.25),
|
||||||
|
new NestPlateStock("wide", new Size(22, 22), 1, 0.6, quadrant: 3) }
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
builder.AddSheet(job.Plates[1], new[] { (proxy.Id, -11.0, -11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal("wide", result.Raw.Plates.Single().StockId);
|
||||||
|
Assert.Equal(3, result.Plates.Single().Stock.Quadrant);
|
||||||
|
Assert.Equal(0.6, result.Plates.Single().Stock.PartSpacing);
|
||||||
|
Assert.Equal(2, result.Plates.Single().Parts.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisteredPluginRunsTheSamePhysicalExpansionBoundary()
|
||||||
|
{
|
||||||
|
var name = "CutoutPlugin-" + Guid.NewGuid().ToString("N");
|
||||||
|
var calls = 0;
|
||||||
|
NestingEngineRegistry.Register(name, "test plugin", () => new Stub(job =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = NestPipeline.RunCutoutPreview(Request(name));
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1" },
|
||||||
|
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
var registry = Assert.IsType<List<NestingEngineInfo>>(NestingEngineRegistry.AvailableEngines);
|
||||||
|
Assert.Equal(1, registry.RemoveAll(info => info.Name == name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TwoCutoutsReserveDifferentCopiesWithinOneFrame()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
foreach (var origin in new[] { 2.0, 12.0 })
|
||||||
|
{
|
||||||
|
program.MoveTo(origin, origin);
|
||||||
|
program.LineTo(origin, origin + 6);
|
||||||
|
program.LineTo(origin + 6, origin + 6);
|
||||||
|
program.LineTo(origin + 6, origin);
|
||||||
|
program.LineTo(origin, origin);
|
||||||
|
}
|
||||||
|
var frame = new Drawing("two-hole-frame", program);
|
||||||
|
var insert = new Drawing("insert", TestShapes.Rectangle(4, 4));
|
||||||
|
var request = new NestPipelineRequest("stub", new[] {
|
||||||
|
new NestItem { Drawing = frame, Quantity = 1 },
|
||||||
|
new NestItem { Drawing = insert, Quantity = 2 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 1, 1, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var inserts = result.Raw.Plates.Single().Placements.Skip(1).ToArray();
|
||||||
|
Assert.Equal(2, inserts.Length);
|
||||||
|
Assert.All(inserts, p =>
|
||||||
|
{
|
||||||
|
var localX = p.X - 1;
|
||||||
|
var localY = p.Y - 1;
|
||||||
|
Assert.Contains(new[] { 2.0, 12.0 }, origin =>
|
||||||
|
localX >= origin + 0.25 && localX + 4 <= origin + 5.75
|
||||||
|
&& localY >= origin + 0.25 && localY + 4 <= origin + 5.75);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentPrioritiesRemainIndependentInsteadOfElevatingAnInsert()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[1].Priority = 3;
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
return Result(job);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, 11.0, 11.0)]
|
||||||
|
[InlineData(2, -11.0, 11.0)]
|
||||||
|
[InlineData(3, -11.0, -11.0)]
|
||||||
|
[InlineData(4, 11.0, -11.0)]
|
||||||
|
public void AllQuadrantsKeepPhysicalStockAndExpansion(int quadrant, double x, double y)
|
||||||
|
{
|
||||||
|
var request = Request("stub") with
|
||||||
|
{
|
||||||
|
Stock = new[] {
|
||||||
|
new NestPlateStock("sheet", new Size(22, 22), 1, 0.25,
|
||||||
|
new Spacing(0.5, 0.5), quadrant) },
|
||||||
|
Options = new NestJobOptions(maxPlates: 1)
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, x, y, 0))),
|
||||||
|
"stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(quadrant, result.Plates.Single().Stock.Quadrant);
|
||||||
|
Assert.Equal((x, y), (result.Raw.Plates.Single().Placements[0].X,
|
||||||
|
result.Raw.Plates.Single().Placements[0].Y));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationAfterAnEngineIgnoresStopDiscardsEveryBundle()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
Assert.Throws<OperationCanceledException>(() => NestPipeline.RunCutoutPreview(
|
||||||
|
new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
cts.Cancel();
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}), "stub", request, token: cts.Token));
|
||||||
|
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompleteStatusWithMissingProxyIsUntrustedAndNotBindable()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
|
||||||
|
NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
Array.Empty<NestJobPlateResult>(),
|
||||||
|
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)),
|
||||||
|
job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)))), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("accounting"));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SuccessfulCompositeReportsOnlyValidatedPhysicalCommitCounts()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var progress = new CollectProgress();
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new ReportingStub(), "stub", request, progress);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { NestJobStage.EvaluatingCandidate, NestJobStage.PlateCommitted },
|
||||||
|
progress.Events.Select(e => e.Stage));
|
||||||
|
Assert.Equal(2, progress.Events.Last().CommittedParts);
|
||||||
|
Assert.Equal(1, progress.Events.Last().CommittedPlates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ReportingStub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
|
||||||
|
job.Plates[0].Id, 0, 0, 0));
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
|
||||||
|
job.Plates[0].Id, 0, 1, 1));
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutomaticBundleRetainsStockFeasibleQuarterTurn()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 10);
|
||||||
|
program.MoveTo(2, 2);
|
||||||
|
program.LineTo(2, 8);
|
||||||
|
program.LineTo(18, 8);
|
||||||
|
program.LineTo(18, 2);
|
||||||
|
program.LineTo(2, 2);
|
||||||
|
var request = new NestPipelineRequest("Irregular", new[] {
|
||||||
|
new NestItem { Drawing = new Drawing("long-frame", program), Quantity = 1 },
|
||||||
|
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1 } },
|
||||||
|
new[] { new NestPlateStock("narrow", new Size(22, 12), 1, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var frame = result.Raw.Plates.Single().Placements[0];
|
||||||
|
Assert.Equal(System.Math.PI / 2, frame.Rotation, 7);
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1" },
|
||||||
|
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChangedLiveInsertDuringSolveNeverBindsUncheckedComposite()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(40, 40);
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("insert"));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NondefaultOptionsPassUnchangedAndPlateCapRejectsOtherwiseValidOutput()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[0].Quantity = 2;
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
var options = new NestJobOptions("Strip", maxPlates: 1,
|
||||||
|
salvageRate: 0.4, minimumSalvageDimension: 2);
|
||||||
|
request = request with
|
||||||
|
{
|
||||||
|
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
|
||||||
|
Options = options
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
Assert.Same(options, job.Options);
|
||||||
|
Assert.Equal("Strip", job.Options.PlacementStrategy);
|
||||||
|
Assert.Equal(0.4, job.Options.SalvageRate);
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
foreach (var proxy in job.Parts)
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("MaxPlates"));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObliqueFramePreservesLegalGlobalInsertRotationAndWallClearance()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
program.MoveTo(5, 5);
|
||||||
|
program.LineTo(5, 15);
|
||||||
|
program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, 5);
|
||||||
|
program.LineTo(5, 5);
|
||||||
|
const double angle = 0.3;
|
||||||
|
var request = new NestPipelineRequest("stub", new[] {
|
||||||
|
new NestItem { Drawing = new Drawing("frame", program), Quantity = 1,
|
||||||
|
StepAngle = 1, RotationStart = angle, RotationEnd = angle },
|
||||||
|
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)),
|
||||||
|
Quantity = 1, StepAngle = 1, RotationStart = angle, RotationEnd = angle } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(40, 40), 1, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, 15, 5, angle))),
|
||||||
|
"stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var poses = result.Raw.Plates.Single().Placements;
|
||||||
|
Assert.Equal(angle, poses[1].Rotation, 8);
|
||||||
|
var dx = poses[1].X - poses[0].X;
|
||||||
|
var dy = poses[1].Y - poses[0].Y;
|
||||||
|
var cosine = System.Math.Cos(angle);
|
||||||
|
var sine = System.Math.Sin(angle);
|
||||||
|
var localX = dx * cosine + dy * sine;
|
||||||
|
var localY = -dx * sine + dy * cosine;
|
||||||
|
foreach (var x in new[] { 0.0, 3.0 })
|
||||||
|
foreach (var y in new[] { 0.0, 3.0 })
|
||||||
|
{
|
||||||
|
Assert.InRange(localX + x, 5.25, 14.75);
|
||||||
|
Assert.InRange(localY + y, 5.25, 14.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeDemandLeavesExcessFramesAndInsertsIndependentAfterBoundedPreparation()
|
||||||
|
{
|
||||||
|
var frame = TestShapes.Rectangle(20, 20);
|
||||||
|
frame.MoveTo(5, 5);
|
||||||
|
frame.LineTo(5, 15);
|
||||||
|
frame.LineTo(15, 15);
|
||||||
|
frame.LineTo(15, 5);
|
||||||
|
frame.LineTo(5, 5);
|
||||||
|
var request = new NestPipelineRequest("stub", new[] {
|
||||||
|
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 40,
|
||||||
|
StepAngle = 1, RotationStart = 0, RotationEnd = 0 },
|
||||||
|
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(6, 6)),
|
||||||
|
Quantity = 40, StepAngle = 1, RotationStart = 0, RotationEnd = 0 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 40, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
Assert.Equal(32, job.Parts.Count(p => p.Id.StartsWith("__cutout-proxy-")));
|
||||||
|
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-0").Quantity);
|
||||||
|
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-1").Quantity);
|
||||||
|
return Result(job);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((40, 0, 40),
|
||||||
|
(f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OrdinaryPipelineIsStillUnchanged()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.Run(new Stub(job =>
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-"));
|
||||||
|
return Result(job);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
using OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||||
|
|
||||||
|
public class CutoutRouterTests
|
||||||
|
{
|
||||||
|
private static NestJobPart Frame()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
program.MoveTo(5, 5);
|
||||||
|
program.LineTo(5, 15);
|
||||||
|
program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, 5);
|
||||||
|
program.LineTo(5, 5);
|
||||||
|
return JobBuilder.Part("frame", program, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SmallLatticeAndLargeResidualKeepTheirOriginalRequirementIds()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var small = JobBuilder.Part("small", TestShapes.Rectangle(1, 1), 3);
|
||||||
|
var large = JobBuilder.Rectangle("large", 3, 3, 1, RotationPolicy.Fixed(0));
|
||||||
|
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25,
|
||||||
|
(pose, lattice) => origins.Add((pose, lattice)));
|
||||||
|
Assert.Equal(3, poses.Count(p => p.PartId == small.Id));
|
||||||
|
Assert.Single(poses, p => p.PartId == large.Id);
|
||||||
|
Assert.Contains(origins, row => row.Pose.PartId == small.Id && row.Lattice);
|
||||||
|
Assert.Contains(origins, row => row.Pose.PartId == large.Id && !row.Lattice);
|
||||||
|
Assert.Equal(poses, origins.Select(row => row.Pose));
|
||||||
|
Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == small.Id).Select(p => p.InstanceIndex));
|
||||||
|
AssertPhysical(frame, new[] { small, large }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImpossibleRemainderNeverDisplacesCertifiedCopies()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var small = JobBuilder.Rectangle("small", 1, 1, 3, RotationPolicy.Fixed(0));
|
||||||
|
var impossible = JobBuilder.Rectangle("impossible", 9.6, 9.6, 1, RotationPolicy.Fixed(0));
|
||||||
|
var together = CutoutRouter.Fill(frame, 0, new[] { small, impossible }, 0.25);
|
||||||
|
Assert.Equal(3, together.Count);
|
||||||
|
Assert.All(together, p => Assert.Equal(small.Id, p.PartId));
|
||||||
|
Assert.Equal(Enumerable.Range(0, 3), together.Select(p => p.InstanceIndex));
|
||||||
|
AssertPhysical(frame, new[] { small, impossible }, together);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PriorCopiesAndQuantityCannotBeBypassedByResidualSearch()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var small = JobBuilder.Rectangle("small", 2, 2, 4, RotationPolicy.Fixed(0));
|
||||||
|
var large = JobBuilder.Rectangle("large", 7, 7, 1, RotationPolicy.Fixed(0));
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25);
|
||||||
|
Assert.InRange(poses.Count(p => p.PartId == small.Id), 0, small.Quantity);
|
||||||
|
Assert.InRange(poses.Count(p => p.PartId == large.Id), 0, large.Quantity);
|
||||||
|
AssertPhysical(frame, new[] { small, large }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResidualNfpDoesNotOverlapTheFirstLargeInsert()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var insert = JobBuilder.Rectangle("large", 6, 6, 2, RotationPolicy.Fixed(0));
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
|
||||||
|
Assert.Single(poses);
|
||||||
|
AssertPhysical(frame, new[] { insert }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NfpAddsOneMoreCopyToPartialShiftedLattice()
|
||||||
|
{
|
||||||
|
// Neutral 8-inch round hole, 2x1 insert, quarter-inch spacing: the shifted
|
||||||
|
// lattice keeps 11; an independently certified NFP pose occupies its leftover.
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
|
||||||
|
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12);
|
||||||
|
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25,
|
||||||
|
(pose, lattice) => origins.Add((pose, lattice)));
|
||||||
|
Assert.Equal(12, poses.Count);
|
||||||
|
Assert.Equal(11, origins.Count(row => row.Lattice));
|
||||||
|
Assert.Single(origins, row => !row.Lattice);
|
||||||
|
Assert.Equal(poses, origins.Select(row => row.Pose));
|
||||||
|
Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex));
|
||||||
|
AssertPhysical(frame, new[] { insert }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MiddleRatioChoosesTwoLatticeCopiesOverOneNfpCopy()
|
||||||
|
{
|
||||||
|
// 4x4 / (pi*5^2) = 0.204. A hard NFP-only threshold at 0.20 loses one copy.
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(4, 4), 3);
|
||||||
|
Assert.InRange(16 / (System.Math.PI * 25), CutoutRouter.SmallRatio, CutoutRouter.LargeRatio);
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
|
||||||
|
Assert.Equal(2, poses.Count);
|
||||||
|
AssertPhysical(frame, new[] { insert }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeRatioKeepsAnNfpPoseWhenFillCannotProduceALattice()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 4.5, 4.5, 3, RotationPolicy.Fixed(0));
|
||||||
|
Assert.True(20.25 / (System.Math.PI * 16) > CutoutRouter.LargeRatio);
|
||||||
|
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, insert, 3, 0.25));
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
|
||||||
|
Assert.Single(poses);
|
||||||
|
AssertPhysical(frame, new[] { insert }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateRequirementIdsAreRejectedBeforePlacement()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var insert = JobBuilder.Rectangle("same", 1, 1, 3);
|
||||||
|
Assert.Throws<ArgumentException>(() => CutoutRouter.Fill(frame, 0,
|
||||||
|
new[] { insert, insert }, 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OccupiedAwareLatticeFillsAfterAnEarlierCopyBlocksItsFirstPose()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var first = JobBuilder.Part("first", TestShapes.Rectangle(1, 1), 1);
|
||||||
|
var more = JobBuilder.Part("more", TestShapes.Rectangle(1, 1), 3);
|
||||||
|
var early = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25);
|
||||||
|
Assert.Equal(3, early.Count);
|
||||||
|
var blocked = early[0] with { PartId = first.Id };
|
||||||
|
var occupied = new[] { (JobPartGeometry.Read(first.Geometry), blocked) };
|
||||||
|
var later = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25,
|
||||||
|
CutoutLatticeFill.DefaultShiftSteps, occupied, default);
|
||||||
|
Assert.Equal(3, later.Count);
|
||||||
|
Assert.All(later, p => Assert.True(NestLayoutCheck.Clears(occupied[0].Item1, blocked,
|
||||||
|
JobPartGeometry.Read(more.Geometry), p, 0.25)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TinyInsertDoesNotAllocateAnUnboundedLattice()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var tiny = JobBuilder.Rectangle("tiny", 0.001, 0.001, 3, RotationPolicy.Fixed(0));
|
||||||
|
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, tiny, 3, 0));
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { tiny }, 0);
|
||||||
|
Assert.Equal(3, poses.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationIsNotNoFit()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
Assert.Throws<OperationCanceledException>(() => CutoutRouter.Fill(Frame(), 0,
|
||||||
|
new[] { JobBuilder.Rectangle("small", 1, 1, 3) }, 0.25, cts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertPhysical(NestJobPart frame, NestJobPart[] inserts,
|
||||||
|
IReadOnlyList<NestJobPlacement> poses)
|
||||||
|
{
|
||||||
|
var stock = JobBuilder.Stock("sheet", 22, 22, 0.25);
|
||||||
|
var job = JobBuilder.Job(new[] { frame }.Concat(inserts).ToArray(), new[] { stock });
|
||||||
|
var frameBounds = JobPartGeometry.Read(frame.Geometry).Bounds;
|
||||||
|
var offsetX = 1 - frameBounds.Left;
|
||||||
|
var offsetY = 1 - frameBounds.Bottom;
|
||||||
|
var placements = new[] { new NestJobPlacement(frame.Id, 0, offsetX, offsetY, 0) }
|
||||||
|
.Concat(poses.Select(p => p with { X = p.X + offsetX, Y = p.Y + offsetY })).ToArray();
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, stock, placements) },
|
||||||
|
new[] { new PartFulfillment(frame.Id, 1, 1, 0) }
|
||||||
|
.Concat(inserts.Select(part => new PartFulfillment(part.Id, part.Quantity,
|
||||||
|
poses.Count(p => p.PartId == part.Id), part.Quantity - poses.Count(p => p.PartId == part.Id))))
|
||||||
|
.ToArray(),
|
||||||
|
new[] { new StockUsage(stock.Id, 1, null) });
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
|
||||||
|
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
||||||
|
foreach (var pose in poses)
|
||||||
|
{
|
||||||
|
var geometry = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry);
|
||||||
|
Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, 0.25));
|
||||||
|
// Independent square-wall oracle: every corner of each rectangular insert clears the hole.
|
||||||
|
var bounds = geometry.Bounds;
|
||||||
|
foreach (var (x, y) in new[] { (bounds.Left, bounds.Bottom), (bounds.Right, bounds.Bottom),
|
||||||
|
(bounds.Right, bounds.Top), (bounds.Left, bounds.Top) })
|
||||||
|
{
|
||||||
|
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
|
||||||
|
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
|
||||||
|
if (frameBounds.Left >= 0)
|
||||||
|
{
|
||||||
|
Assert.InRange(rx, 5.25, 14.75);
|
||||||
|
Assert.InRange(ry, 5.25, 14.75);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var radius = System.Math.Abs(frameGeometry.Cutouts[0].BoundingBox.Right);
|
||||||
|
Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= radius - 0.25 + 0.00001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Independent rectangle-vs-rectangle distance, not the production Clears predicate.
|
||||||
|
for (var i = 0; i < poses.Count; i++)
|
||||||
|
for (var j = i + 1; j < poses.Count; j++)
|
||||||
|
{
|
||||||
|
var a = Bounds(poses[i]);
|
||||||
|
var b = Bounds(poses[j]);
|
||||||
|
var dx = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right));
|
||||||
|
var dy = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top));
|
||||||
|
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.25 - 0.00001,
|
||||||
|
$"independent material gap too small: {poses[i]} vs {poses[j]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
(double Left, double Right, double Bottom, double Top) Bounds(NestJobPlacement pose)
|
||||||
|
{
|
||||||
|
var box = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry).Bounds;
|
||||||
|
var corners = new[] { (box.Left, box.Bottom), (box.Right, box.Bottom),
|
||||||
|
(box.Right, box.Top), (box.Left, box.Top) };
|
||||||
|
var x = corners.Select(c => pose.X + c.Item1 * System.Math.Cos(pose.Rotation)
|
||||||
|
- c.Item2 * System.Math.Sin(pose.Rotation)).ToArray();
|
||||||
|
var y = corners.Select(c => pose.Y + c.Item1 * System.Math.Sin(pose.Rotation)
|
||||||
|
+ c.Item2 * System.Math.Cos(pose.Rotation)).ToArray();
|
||||||
|
return (x.Min(), x.Max(), y.Min(), y.Max());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,73 @@ public class NestPipelineCommitTests
|
|||||||
Assert.Equal(2, Assert.Single(applied).Parts.Count);
|
Assert.Equal(2, Assert.Single(applied).Parts.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawingChangedAfterValidationIsRefusedBeforeCommitEvenWithConsent()
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
var empty = nest.CreatePlate();
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
var result = NestPipeline.Run(new PreMutationStub(), "stub",
|
||||||
|
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
drawing.Program = TestDrawingFactory.Rectangle(100, 100);
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
|
||||||
|
Assert.Same(empty, Assert.Single(nest.Plates));
|
||||||
|
Assert.Empty(empty.Parts);
|
||||||
|
Assert.Equal(0, drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PreMutationStub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (job.Parts[0].Id, 1.0, 1.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CancelDuringSnapshot(double x, double y, CancellationTokenSource source)
|
||||||
|
: OpenNest.CNC.LinearMove(x, y)
|
||||||
|
{
|
||||||
|
public override OpenNest.CNC.CodeType Type
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
source.Cancel();
|
||||||
|
return base.Type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationDuringFreshnessScanDoesNotMutateNest()
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
var empty = nest.CreatePlate();
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
var result = NestPipeline.Run(new PreMutationStub(), "stub",
|
||||||
|
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var proposed = Assert.Single(Assert.Single(result.Plates).Parts);
|
||||||
|
proposed.Program.Codes[1] = new CancelDuringSnapshot(10, 0, cts);
|
||||||
|
Assert.Throws<OperationCanceledException>(() =>
|
||||||
|
NestPipelineCommit.ApplyToEmptyPlates(result, manager, token: cts.Token));
|
||||||
|
Assert.True(cts.IsCancellationRequested);
|
||||||
|
Assert.Same(empty, Assert.Single(nest.Plates));
|
||||||
|
Assert.Empty(empty.Parts);
|
||||||
|
Assert.Equal(0, drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CancelledCommitDoesNotCreateAnyPlate()
|
public void CancelledCommitDoesNotCreateAnyPlate()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -258,6 +258,44 @@ public class NestPipelineTests
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawingChangedDuringSolveCannotBindGeometryOtherThanTheValidatedSnapshot()
|
||||||
|
{
|
||||||
|
var item = Item("bracket", 1);
|
||||||
|
var result = NestPipeline.Run(new StubEngine(job =>
|
||||||
|
{
|
||||||
|
item.Drawing.Program = TestDrawingFactory.Rectangle(100, 100);
|
||||||
|
return OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0));
|
||||||
|
}), "Mutator", Request("Mutator", item));
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("bracket"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TransientCloneMove(double x, double y) : LinearMove(x, y)
|
||||||
|
{
|
||||||
|
public override ICode Clone()
|
||||||
|
{
|
||||||
|
var before = EndPoint;
|
||||||
|
EndPoint = new Vector(100, 100);
|
||||||
|
try { return base.Clone(); }
|
||||||
|
finally { EndPoint = before; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BindingTransientDrawingEditCannotReturnOrCommitUncheckedGeometry()
|
||||||
|
{
|
||||||
|
var item = Item("bracket", 1);
|
||||||
|
item.Drawing.Program.Codes[^1] = new TransientCloneMove(0, 0);
|
||||||
|
var result = NestPipeline.Run(new StubEngine(job =>
|
||||||
|
OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0))),
|
||||||
|
"Transient", Request("Transient", item));
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("Bound part") && v.Contains("bracket"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CancellationPropagatesWithoutAResult()
|
public void CancellationPropagatesWithoutAResult()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A deliberately uncertified fallback, not a relaxed safety checker. Reuses closed native
|
||||||
|
/// contour preparation and emission, retaining source order except for proven prerequisites.
|
||||||
|
/// Unknown program semantics, invalid motions and unrepresentable contours still refuse.
|
||||||
|
/// </summary>
|
||||||
|
internal static class BestEffortCuttingPlan
|
||||||
|
{
|
||||||
|
internal static CuttingPlanResult Plan(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (snapshot.Failure is { } failure)
|
||||||
|
return new(failure, findings: snapshot.Findings);
|
||||||
|
if (!snapshot.Regeneration || snapshot.OwnedParameters == null)
|
||||||
|
return new(CuttingPlanStatus.InvalidInput);
|
||||||
|
var findings = snapshot.Findings.ToList();
|
||||||
|
findings.Add(new(null, null, null, null, PostVerificationKind.Incomplete,
|
||||||
|
"Best-effort plan: lead clearance, rapid travel and material containment are not certified. "
|
||||||
|
+ "Review the complete plan before cutting. Source contours have not been repaired."));
|
||||||
|
var order = new List<FixedProgramPlacement>();
|
||||||
|
var done = new HashSet<int>();
|
||||||
|
var position = snapshot.StartPoint;
|
||||||
|
var distance = 0.0;
|
||||||
|
var checker = new ReleasedContourState();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (order.Count < snapshot.Placements.Count)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var source = snapshot.PreservePartOrder ? snapshot.Placements[order.Count]
|
||||||
|
: snapshot.Placements.FirstOrDefault(p => !done.Contains(p.SourceOrdinal)
|
||||||
|
&& snapshot.Dependencies.IsReady(p.SourceOrdinal, done));
|
||||||
|
if (source == null || !snapshot.Dependencies.IsReady(source.SourceOrdinal, done))
|
||||||
|
return new(CuttingPlanStatus.ConstraintConflict, findings: findings);
|
||||||
|
var proposal = source;
|
||||||
|
if (source.Prepared is { } prepared)
|
||||||
|
{
|
||||||
|
var choices = new List<ContourChoice>();
|
||||||
|
var approach = position - source.Location;
|
||||||
|
for (var contour = 0; contour < prepared.Count; contour++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
choices.Add(prepared.ClosestEntry(contour, approach));
|
||||||
|
// Prefixes are standalone programs, not relative to the prior prefix.
|
||||||
|
var prefix = ExecutionMotionReader.ReadSupported(prepared.EmitPrefix(choices),
|
||||||
|
source.Location, position, token);
|
||||||
|
approach = prefix.DeparturePoint - source.Location;
|
||||||
|
}
|
||||||
|
var program = prepared.Emit(choices);
|
||||||
|
var emitted = ExecutionMotionReader.ReadSupported(program, source.Location, position, token);
|
||||||
|
proposal = source.Propose(program, emitted, choices, token);
|
||||||
|
}
|
||||||
|
// Read the owned payload again; malformed/nonfinite output never reaches Apply.
|
||||||
|
var execution = ExecutionMotionReader.ReadSupported(proposal.CopyProgram(), source.Location, position, token);
|
||||||
|
if (!execution.HasCuttingContour)
|
||||||
|
throw new ArgumentException("Best-effort output has no cutting contour.");
|
||||||
|
findings.AddRange(checker.Check(execution, position, source.SourceOrdinal + 1, source.IsCutOff, token)
|
||||||
|
.Select(f => new CuttingPlanFinding(f.PartNumber is int part ? part - 1 : null,
|
||||||
|
f.PartNumber is int ordinal ? snapshot.Placements[ordinal - 1].SourcePart : null,
|
||||||
|
f.OtherPartNumber is int other ? other - 1 : null,
|
||||||
|
f.OtherPartNumber is int otherOrdinal ? snapshot.Placements[otherOrdinal - 1].SourcePart : null,
|
||||||
|
f.Kind, f.Message)));
|
||||||
|
distance += execution.RapidDistanceFrom(position);
|
||||||
|
if (!double.IsFinite(distance))
|
||||||
|
throw new ArgumentException("Best-effort travel is not finite.");
|
||||||
|
position = execution.DeparturePoint;
|
||||||
|
done.Add(source.SourceOrdinal);
|
||||||
|
order.Add(proposal);
|
||||||
|
}
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
return new(CuttingPlanStatus.BestEffort, order, findings, rapidDistance: distance)
|
||||||
|
{ Snapshot = snapshot };
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return new(CuttingPlanStatus.Cancelled);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is ArgumentException or NotSupportedException
|
||||||
|
or InvalidOperationException or ArithmeticException)
|
||||||
|
{
|
||||||
|
findings.Add(new(null, null, null, null, PostVerificationKind.Incomplete, exception.Message));
|
||||||
|
return new(CuttingPlanStatus.UnsupportedGeometry, findings: findings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@ internal sealed class CuttingDependencyGraph
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal static CuttingDependencyGraph Build(IReadOnlyList<DependencyNode> nodes, Box plate,
|
internal static CuttingDependencyGraph Build(IReadOnlyList<DependencyNode> nodes, Box plate,
|
||||||
CancellationToken token)
|
CancellationToken token, Action<CuttingDependencyException> uncertain = null)
|
||||||
{
|
{
|
||||||
var edges = nodes.Select(_ => new SortedSet<int>()).ToArray();
|
var edges = nodes.Select(_ => new SortedSet<int>()).ToArray();
|
||||||
for (var cut = 0; cut < nodes.Count; cut++)
|
for (var cut = 0; cut < nodes.Count; cut++)
|
||||||
@@ -98,9 +98,24 @@ internal sealed class CuttingDependencyGraph
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
// Bounds only select candidates; containment itself is proven on native material.
|
// Bounds only select candidates; containment itself is proven on native material.
|
||||||
if (inner != host && !nodes[inner].IsCutOff && !nodes[host].IsCutOff
|
if (inner != host && !nodes[inner].IsCutOff && !nodes[host].IsCutOff
|
||||||
&& Within(nodes[inner].CleanBounds, nodes[host].HostBounds)
|
&& Within(nodes[inner].CleanBounds, nodes[host].HostBounds))
|
||||||
&& InsideCutout(nodes, inner, host, token))
|
{
|
||||||
edges[host].Add(inner);
|
try
|
||||||
|
{
|
||||||
|
if (InsideCutout(nodes, inner, host, token))
|
||||||
|
edges[host].Add(inner);
|
||||||
|
}
|
||||||
|
catch (CuttingDependencyException exception) when (uncertain != null
|
||||||
|
&& exception.Status == CuttingPlanStatus.UnsupportedGeometry)
|
||||||
|
{
|
||||||
|
uncertain(exception);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (uncertain != null
|
||||||
|
&& exception is ArgumentException or NotSupportedException)
|
||||||
|
{
|
||||||
|
uncertain(Ambiguous(inner, host, exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var graph = new CuttingDependencyGraph(edges.Select(e => e.ToArray()).ToArray());
|
var graph = new CuttingDependencyGraph(edges.Select(e => e.ToArray()).ToArray());
|
||||||
if (graph.FindCycle() is { } cyclic)
|
if (graph.FindCycle() is { } cyclic)
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ public sealed class CuttingPlanBatch
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const int ExpansionsPerPart = 400;
|
public const int ExpansionsPerPart = 400;
|
||||||
|
|
||||||
|
// Extra bounded work on hard/refused plates; the full-cap search still gets its whole budget.
|
||||||
|
internal const int FirstPassBudget = 1000;
|
||||||
|
internal const int FirstPassEntries = 8;
|
||||||
|
|
||||||
private readonly Entry[] entries;
|
private readonly Entry[] entries;
|
||||||
private readonly CuttingParameters ownedParameters;
|
private readonly CuttingParameters ownedParameters;
|
||||||
|
|
||||||
@@ -99,7 +103,11 @@ public sealed class CuttingPlanBatch
|
|||||||
var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||||
expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), token);
|
expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), token);
|
||||||
var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
|
var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
|
||||||
entries[index] = new(plate, plateNumbers?[index] ?? index + 1, reorder, keepOrder, overlap);
|
var fallback = reorder ?? keepOrder;
|
||||||
|
if (fallback.Failure == CuttingPlanStatus.UnsupportedGeometry)
|
||||||
|
fallback = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||||
|
confirmedParameters: confirmedParameters, preservePartOrder: preservePartOrder), token, bestEffort: true);
|
||||||
|
entries[index] = new(plate, plateNumbers?[index] ?? index + 1, reorder, keepOrder, overlap, fallback);
|
||||||
}
|
}
|
||||||
return new(entries, owned);
|
return new(entries, owned);
|
||||||
}
|
}
|
||||||
@@ -112,7 +120,8 @@ public sealed class CuttingPlanBatch
|
|||||||
/// Checks and plans every plate from its captured snapshots. Safe on a worker: live plates are
|
/// Checks and plans every plate from its captured snapshots. Safe on a worker: live plates are
|
||||||
/// not read. Overlapping material blocks a plate whatever its route. A free search that ends
|
/// not read. Overlapping material blocks a plate whatever its route. A free search that ends
|
||||||
/// without a complete plan within its budget is retried with the current order; the proposal
|
/// without a complete plan within its budget is retried with the current order; the proposal
|
||||||
/// reports that it kept the order.
|
/// reports that it kept the order. Incomplete geometry also gets a separately labelled,
|
||||||
|
/// unverified fallback when supported closed contours can still be emitted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public CuttingPlanProposal Plan(IProgress<CuttingPlanProgress> progress = null,
|
public CuttingPlanProposal Plan(IProgress<CuttingPlanProgress> progress = null,
|
||||||
CancellationToken token = default)
|
CancellationToken token = default)
|
||||||
@@ -137,22 +146,50 @@ public sealed class CuttingPlanBatch
|
|||||||
if (entry.Reorder != null)
|
if (entry.Reorder != null)
|
||||||
{
|
{
|
||||||
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
|
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
|
||||||
reorder = CuttingPlanService.Plan(entry.Reorder, token);
|
reorder = PlanReorder(entry.Reorder, token);
|
||||||
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
|
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
|
||||||
{
|
{
|
||||||
plans[index] = new(entry.Plate, entry.Number, reorder, null, overlap);
|
plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.KeepingOrder));
|
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.KeepingOrder));
|
||||||
plans[index] = new(entry.Plate, entry.Number, CuttingPlanService.Plan(entry.KeepOrder, token), reorder,
|
plans[index] = new(entry.Plate, entry.Number,
|
||||||
overlap);
|
WithFallback(entry, CuttingPlanService.Plan(entry.KeepOrder, token), token), reorder, overlap);
|
||||||
}
|
}
|
||||||
return new(plans, ownedParameters);
|
return new(plans, ownedParameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
|
||||||
|
int firstPassBudget = FirstPassBudget)
|
||||||
|
{
|
||||||
|
// A failed first pass is not a proof of impossibility: retain all original
|
||||||
|
// full-cap alternatives and the complete original budget for the retry.
|
||||||
|
if (snapshot.Failure == null && snapshot.Regeneration && snapshot.MaxEntries > FirstPassEntries
|
||||||
|
&& snapshot.ExpansionBudget > firstPassBudget)
|
||||||
|
{
|
||||||
|
var first = CuttingPlanService.Plan(snapshot.WithSearchLimits(firstPassBudget, FirstPassEntries), token);
|
||||||
|
if ((first.Status == CuttingPlanStatus.Ready && first.IndependentlyReplayed)
|
||||||
|
|| first.Status == CuttingPlanStatus.Cancelled)
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
return CuttingPlanService.Plan(snapshot, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null)
|
||||||
|
return strict;
|
||||||
|
var fallback = BestEffortCuttingPlan.Plan(entry.Fallback, token);
|
||||||
|
if (fallback.Status != CuttingPlanStatus.BestEffort)
|
||||||
|
return fallback.Status == CuttingPlanStatus.Cancelled ? fallback : strict;
|
||||||
|
return new(CuttingPlanStatus.BestEffort, fallback.ProposedOrder,
|
||||||
|
strict.Findings.Concat(fallback.Findings).Distinct(), rapidDistance: fallback.RapidDistance)
|
||||||
|
{ Snapshot = entry.Fallback };
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record Entry(Plate Plate, int Number, CuttingPlanSnapshot Reorder, CuttingPlanSnapshot KeepOrder,
|
private sealed record Entry(Plate Plate, int Number, CuttingPlanSnapshot Reorder, CuttingPlanSnapshot KeepOrder,
|
||||||
PlateOverlapSnapshot Overlap);
|
PlateOverlapSnapshot Overlap, CuttingPlanSnapshot Fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One plate's outcome inside a <see cref="CuttingPlanProposal"/>.</summary>
|
/// <summary>One plate's outcome inside a <see cref="CuttingPlanProposal"/>.</summary>
|
||||||
@@ -188,6 +225,10 @@ public sealed class CuttingPlanPlateResult
|
|||||||
|
|
||||||
public bool IsOverlapClear => Overlap is { IsComplete: true } && Overlap.Pairs.Count == 0;
|
public bool IsOverlapClear => Overlap is { IsComplete: true } && Overlap.Pairs.Count == 0;
|
||||||
public bool IsReady => IsRouteReady && IsOverlapClear;
|
public bool IsReady => IsRouteReady && IsOverlapClear;
|
||||||
|
|
||||||
|
/// <summary>Owned readable output is available, but not all geometric checks passed.</summary>
|
||||||
|
public bool CanApplyWithWarnings => Overlap != null && Overlap.Pairs.Count == 0
|
||||||
|
&& (IsRouteReady || Result.Status == CuttingPlanStatus.BestEffort);
|
||||||
public int PartCount => Result.ProposedOrder.Count;
|
public int PartCount => Result.ProposedOrder.Count;
|
||||||
public int RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated);
|
public int RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated);
|
||||||
|
|
||||||
@@ -196,7 +237,7 @@ public sealed class CuttingPlanPlateResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The outcome of a batch. Apply is all-or-nothing and is offered only when every plate is ready.
|
/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CuttingPlanProposal
|
public sealed class CuttingPlanProposal
|
||||||
{
|
{
|
||||||
@@ -216,17 +257,26 @@ public sealed class CuttingPlanProposal
|
|||||||
public bool CanApply => Plates.Count > 0 && !IsCancelled && ownedParameters != null
|
public bool CanApply => Plates.Count > 0 && !IsCancelled && ownedParameters != null
|
||||||
&& Plates.All(p => p.IsReady);
|
&& Plates.All(p => p.IsReady);
|
||||||
|
|
||||||
|
/// <summary>All plates have usable output, possibly requiring explicit warning acceptance.</summary>
|
||||||
|
public bool CanApplyWithWarnings => Plates.Count > 0 && !IsCancelled && ownedParameters != null
|
||||||
|
&& Plates.All(p => p.CanApplyWithWarnings);
|
||||||
|
|
||||||
|
public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the
|
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the
|
||||||
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
|
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
|
||||||
/// of the confirmed parameters as its cutting settings; any other status changes nothing.
|
/// of the confirmed parameters as its cutting settings; any other status changes nothing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public CuttingCommitResult Apply(CancellationToken token = default)
|
public CuttingCommitResult Apply(CancellationToken token = default) => Apply(false, token);
|
||||||
|
|
||||||
|
/// <summary>Explicit per-proposal acceptance; never grants consent to post CNC output.</summary>
|
||||||
|
public CuttingCommitResult Apply(bool acceptWarnings, CancellationToken token = default)
|
||||||
{
|
{
|
||||||
if (!CanApply)
|
if (!(CanApply || acceptWarnings && CanApplyWithWarnings))
|
||||||
return new(CuttingCommitStatus.InvalidInput,
|
return new(CuttingCommitStatus.InvalidInput,
|
||||||
"Every plate must have a ready plan before anything is applied.");
|
"Every plate must have a ready plan before anything is applied.");
|
||||||
var commit = CuttingPlanService.Apply(Plates.Select(p => p.Result), token);
|
var commit = CuttingPlanService.Apply(Plates.Select(p => p.Result), token, null, acceptWarnings);
|
||||||
if (commit.Status == CuttingCommitStatus.Applied)
|
if (commit.Status == CuttingCommitStatus.Applied)
|
||||||
foreach (var plate in Plates)
|
foreach (var plate in Plates)
|
||||||
plate.Plate.CuttingParameters = OwnedCuttingParameters.Copy(ownedParameters);
|
plate.Plate.CuttingParameters = OwnedCuttingParameters.Copy(ownedParameters);
|
||||||
@@ -234,18 +284,17 @@ public sealed class CuttingPlanProposal
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A detached copy of a ready plate for display, in the proposed order with the proposed
|
/// A detached copy of a ready or explicitly unverified proposal for display, or null.
|
||||||
/// programs, or null. A refused plate is not previewed: its program graphs may be unsafe to
|
/// Refused graphs are never cloned. Changed plates are not previewed at uncaptured poses.
|
||||||
/// copy. Neither is a plate that changed after planning, which would draw replayed programs at
|
/// The copy has quantity zero, so drawing quantities stay put.
|
||||||
/// poses that were never checked. The copy has quantity zero, so drawing quantities stay put.
|
|
||||||
/// Call it on the thread that owns the plates.
|
/// Call it on the thread that owns the plates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Plate BuildPreview(int index)
|
public Plate BuildPreview(int index)
|
||||||
{
|
{
|
||||||
var planned = Plates[index];
|
var planned = Plates[index];
|
||||||
if (!planned.IsReady || planned.Result.Snapshot?.PlateState?.IsCurrent() != true)
|
if (!planned.CanApplyWithWarnings || planned.Result.Snapshot?.PlateState?.IsCurrent() != true)
|
||||||
return null;
|
return null;
|
||||||
// Unchanged since capture: live poses, plate and programs are the validated, replayed ones.
|
// Unchanged since capture: graphs are supported and the preview uses the exact proposal.
|
||||||
var source = planned.Plate;
|
var source = planned.Plate;
|
||||||
var preview = new Plate(source.Size)
|
var preview = new Plate(source.Size)
|
||||||
{
|
{
|
||||||
@@ -274,6 +323,9 @@ public sealed class CuttingPlanProposal
|
|||||||
lines.Add($"Ready to apply to {Count(Plates.Count, "plate")}: "
|
lines.Add($"Ready to apply to {Count(Plates.Count, "plate")}: "
|
||||||
+ $"{Count(Plates.Count(p => p.OrderChanged), "plate")} with a new part order, "
|
+ $"{Count(Plates.Count(p => p.OrderChanged), "plate")} with a new part order, "
|
||||||
+ $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated.");
|
+ $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated.");
|
||||||
|
else if (RequiresWarningAcceptance)
|
||||||
|
lines.Add("Best-effort plan available. Review the warnings and accept the unverified plan to apply. "
|
||||||
|
+ "This is not approval to cut or post CNC output.");
|
||||||
else
|
else
|
||||||
lines.Add($"Apply is unavailable: {Plates.Count - ready} of {Count(Plates.Count, "plate")} could not "
|
lines.Add($"Apply is unavailable: {Plates.Count - ready} of {Count(Plates.Count, "plate")} could not "
|
||||||
+ "be planned. No plate changes until every plate is ready.");
|
+ "be planned. No plate changes until every plate is ready.");
|
||||||
@@ -307,6 +359,17 @@ public sealed class CuttingPlanProposal
|
|||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (plate.CanApplyWithWarnings)
|
||||||
|
{
|
||||||
|
yield return heading + $"best-effort, unverified. {Count(plate.PartCount, "part")}, "
|
||||||
|
+ $"{plate.RegeneratedCount} regenerated; review lead-ins and cutting order.";
|
||||||
|
// Unverified Apply requires the operator to see every finding, not just a
|
||||||
|
// bounded diagnostic summary that hides later parts behind "... and N more".
|
||||||
|
foreach (var line in DescribeOverlap(plate.Overlap).Concat(result.Findings.Select(DescribeFinding)))
|
||||||
|
yield return " - " + line;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
var missingLead = !plate.IsRouteReady && result.Status != CuttingPlanStatus.Cancelled
|
var missingLead = !plate.IsRouteReady && result.Status != CuttingPlanStatus.Cancelled
|
||||||
&& result.Findings.Any(f => f.Kind == PostVerificationKind.MissingLeadIn);
|
&& result.Findings.Any(f => f.Kind == PostVerificationKind.MissingLeadIn);
|
||||||
yield return heading + (plate.IsRouteReady
|
yield return heading + (plate.IsRouteReady
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ public sealed class CuttingPlanSnapshot
|
|||||||
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null,
|
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null,
|
||||||
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
|
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
|
||||||
Action<int> expansionObserver = null, PlateCuttingState plateState = null,
|
Action<int> expansionObserver = null, PlateCuttingState plateState = null,
|
||||||
CuttingParameters ownedParameters = null, CuttingDependencyGraph dependencies = null)
|
CuttingParameters ownedParameters = null, CuttingDependencyGraph dependencies = null, bool bestEffort = false)
|
||||||
{
|
{
|
||||||
|
BestEffort = bestEffort;
|
||||||
PlateState = plateState;
|
PlateState = plateState;
|
||||||
OwnedParameters = ownedParameters;
|
OwnedParameters = ownedParameters;
|
||||||
Placements = Array.AsReadOnly(placements.ToArray());
|
Placements = Array.AsReadOnly(placements.ToArray());
|
||||||
@@ -82,6 +83,7 @@ public sealed class CuttingPlanSnapshot
|
|||||||
internal PlateCuttingState PlateState { get; }
|
internal PlateCuttingState PlateState { get; }
|
||||||
/// <summary>Owned copy of the confirmed parameters taken at capture; never the caller's object.</summary>
|
/// <summary>Owned copy of the confirmed parameters taken at capture; never the caller's object.</summary>
|
||||||
internal CuttingParameters OwnedParameters { get; }
|
internal CuttingParameters OwnedParameters { get; }
|
||||||
|
internal bool BestEffort { get; }
|
||||||
internal bool Regeneration { get; }
|
internal bool Regeneration { get; }
|
||||||
internal bool PreservePartOrder { get; }
|
internal bool PreservePartOrder { get; }
|
||||||
internal int MaxEntries { get; }
|
internal int MaxEntries { get; }
|
||||||
@@ -91,6 +93,13 @@ public sealed class CuttingPlanSnapshot
|
|||||||
public int ExpansionBudget { get; }
|
public int ExpansionBudget { get; }
|
||||||
internal CuttingPlanStatus? Failure { get; }
|
internal CuttingPlanStatus? Failure { get; }
|
||||||
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
|
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
|
||||||
|
|
||||||
|
// A worker-only search variant over the same owned capture. Never recapture the live plate:
|
||||||
|
// both proposals retain the identical freshness record and confirmed settings.
|
||||||
|
internal CuttingPlanSnapshot WithSearchLimits(int expansionBudget, int maxEntries) =>
|
||||||
|
new(Placements, StartPoint, expansionBudget, Failure, Findings, Regeneration,
|
||||||
|
PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters,
|
||||||
|
Dependencies, BestEffort);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class FixedProgramPlacement
|
public sealed class FixedProgramPlacement
|
||||||
@@ -159,7 +168,9 @@ public enum CuttingPlanStatus
|
|||||||
UnsupportedGeometry,
|
UnsupportedGeometry,
|
||||||
InvalidInput,
|
InvalidInput,
|
||||||
NoSolutionWithinBudget,
|
NoSolutionWithinBudget,
|
||||||
Cancelled
|
Cancelled,
|
||||||
|
/// <summary>Usable owned programs, but geometric safety checks did not pass. Never Ready.</summary>
|
||||||
|
BestEffort
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ordinals are zero-based source positions, not proposed sequence positions.</summary>
|
/// <summary>Ordinals are zero-based source positions, not proposed sequence positions.</summary>
|
||||||
@@ -169,7 +180,8 @@ public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart,
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// A replayed direct-XY proposal, optionally with regenerated programs, that respects the
|
/// A replayed direct-XY proposal, optionally with regenerated programs, that respects the
|
||||||
/// captured cutoff/containment prerequisites. Not physical safety or posting consent. Apply
|
/// captured cutoff/containment prerequisites. Not physical safety or posting consent. Apply
|
||||||
/// installs it only for a plate-scoped request whose plate is unchanged. Failures contain no proposals.
|
/// installs it only for a plate-scoped request whose plate is unchanged. BestEffort is explicitly
|
||||||
|
/// unverified and requires batch warning acceptance; other failures contain no proposals.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CuttingPlanResult
|
public sealed class CuttingPlanResult
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ public static class CuttingPlanService
|
|||||||
/// Read stable caller-owned sources once into privately owned programs and geometry.
|
/// Read stable caller-owned sources once into privately owned programs and geometry.
|
||||||
/// No private Plates, settings aliases, quantity updates or source subcall rebinding.
|
/// No private Plates, settings aliases, quantity updates or source subcall rebinding.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default)
|
public static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token = default) =>
|
||||||
|
Capture(request, token, bestEffort: false);
|
||||||
|
|
||||||
|
internal static CuttingPlanSnapshot Capture(CuttingPlanRequest request, CancellationToken token, bool bestEffort)
|
||||||
{
|
{
|
||||||
var placements = new List<FixedProgramPlacement>();
|
var placements = new List<FixedProgramPlacement>();
|
||||||
|
var warnings = new List<CuttingPlanFinding>();
|
||||||
Part source = null;
|
Part source = null;
|
||||||
int? ordinal = null;
|
int? ordinal = null;
|
||||||
try
|
try
|
||||||
@@ -107,7 +111,11 @@ public static class CuttingPlanService
|
|||||||
{
|
{
|
||||||
material = LeadMaterialSnapshot.Capture(ownedClean, location, token);
|
material = LeadMaterialSnapshot.Capture(ownedClean, location, token);
|
||||||
if (!material.IsComplete)
|
if (!material.IsComplete)
|
||||||
throw new NotSupportedException(material.Reason);
|
{
|
||||||
|
if (!bestEffort)
|
||||||
|
throw new NotSupportedException(material.Reason);
|
||||||
|
warnings.Add(new(index, source, null, null, PostVerificationKind.Incomplete, material.Reason));
|
||||||
|
}
|
||||||
if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source)))
|
if (!source.LeadInsLocked && (request.EligibleParts == null || eligible.Contains(source)))
|
||||||
prepared = PreparedContours.Capture(ownedClean, request.ConfirmedParameters, token);
|
prepared = PreparedContours.Capture(ownedClean, request.ConfirmedParameters, token);
|
||||||
}
|
}
|
||||||
@@ -122,13 +130,16 @@ public static class CuttingPlanService
|
|||||||
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
|
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
|
||||||
source = null;
|
source = null;
|
||||||
ordinal = null;
|
ordinal = null;
|
||||||
var dependencies = CuttingDependencyGraph.Build(nodes, request.Plate?.BoundingBox(includeParts: false), token);
|
var dependencies = CuttingDependencyGraph.Build(nodes, request.Plate?.BoundingBox(includeParts: false), token,
|
||||||
|
bestEffort ? exception => warnings.Add(new(exception.Ordinal, request.Parts[exception.Ordinal],
|
||||||
|
exception.Other, exception.Other is int other ? request.Parts[other] : null,
|
||||||
|
PostVerificationKind.Incomplete, exception.Message)) : null);
|
||||||
return new(placements, request.StartPoint, request.ExpansionBudget, regeneration: request.ConfirmedParameters != null,
|
return new(placements, request.StartPoint, request.ExpansionBudget, regeneration: request.ConfirmedParameters != null,
|
||||||
preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries,
|
preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries,
|
||||||
expansionObserver: request.ExpansionObserver, plateState: plateState,
|
expansionObserver: request.ExpansionObserver, plateState: plateState,
|
||||||
ownedParameters: request.ConfirmedParameters == null ? null
|
ownedParameters: request.ConfirmedParameters == null ? null
|
||||||
: OwnedCuttingParameters.Copy(request.ConfirmedParameters),
|
: OwnedCuttingParameters.Copy(request.ConfirmedParameters),
|
||||||
dependencies: dependencies);
|
dependencies: dependencies, findings: warnings, bestEffort: bestEffort);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -175,14 +186,15 @@ public static class CuttingPlanService
|
|||||||
|
|
||||||
// beforeInstall is the commit's install-boundary test seam.
|
// beforeInstall is the commit's install-boundary test seam.
|
||||||
internal static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token,
|
internal static CuttingCommitResult Apply(IEnumerable<CuttingPlanResult> results, CancellationToken token,
|
||||||
Action<Plate, Part> beforeInstall)
|
Action<Plate, Part> beforeInstall, bool acceptUnverified = false)
|
||||||
{
|
{
|
||||||
var plans = new List<PlateCuttingPlan>();
|
var plans = new List<PlateCuttingPlan>();
|
||||||
foreach (var result in results ?? [])
|
foreach (var result in results ?? [])
|
||||||
{
|
{
|
||||||
var snapshot = result?.Snapshot;
|
var snapshot = result?.Snapshot;
|
||||||
if (result?.Status != CuttingPlanStatus.Ready || !result.IndependentlyReplayed
|
var eligible = result != null && (result.Status == CuttingPlanStatus.Ready && result.IndependentlyReplayed
|
||||||
|| snapshot?.PlateState == null || result.ProposedOrder.Count != snapshot.Placements.Count)
|
|| acceptUnverified && result.Status == CuttingPlanStatus.BestEffort);
|
||||||
|
if (!eligible || snapshot?.PlateState == null || result.ProposedOrder.Count != snapshot.Placements.Count)
|
||||||
return new(CuttingCommitStatus.InvalidInput,
|
return new(CuttingCommitStatus.InvalidInput,
|
||||||
"Only Ready, independently replayed plate-scoped proposals can be applied.");
|
"Only Ready, independently replayed plate-scoped proposals can be applied.");
|
||||||
var programs = new List<PlannedPartProgram>();
|
var programs = new List<PlannedPartProgram>();
|
||||||
@@ -211,6 +223,8 @@ public static class CuttingPlanService
|
|||||||
return new(CuttingPlanStatus.InvalidInput);
|
return new(CuttingPlanStatus.InvalidInput);
|
||||||
if (snapshot.Failure is { } failure)
|
if (snapshot.Failure is { } failure)
|
||||||
return new(failure, findings: snapshot.Findings);
|
return new(failure, findings: snapshot.Findings);
|
||||||
|
if (snapshot.BestEffort)
|
||||||
|
return BestEffortCuttingPlan.Plan(snapshot, token);
|
||||||
if (snapshot.Placements.Count == 0)
|
if (snapshot.Placements.Count == 0)
|
||||||
return snapshot.PlateState == null ? new(CuttingPlanStatus.InvalidInput)
|
return snapshot.PlateState == null ? new(CuttingPlanStatus.InvalidInput)
|
||||||
: new(CuttingPlanStatus.Ready, independentlyReplayed: true); // Empty plate: unchanged no-op.
|
: new(CuttingPlanStatus.Ready, independentlyReplayed: true); // Empty plate: unchanged no-op.
|
||||||
|
|||||||
@@ -196,29 +196,39 @@ internal static class JointCuttingPlanSearch
|
|||||||
var progressExpansions = Expansions;
|
var progressExpansions = Expansions;
|
||||||
var stack = new Stack<Frame>();
|
var stack = new Stack<Frame>();
|
||||||
stack.Push(new(root));
|
stack.Push(new(root));
|
||||||
while (stack.Count != 0)
|
try
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
while (stack.Count != 0)
|
||||||
var frame = stack.Peek();
|
|
||||||
var node = frame.Node;
|
|
||||||
if (node.Order.Length == snapshot.Placements.Count)
|
|
||||||
{
|
{
|
||||||
attempt.Order = node.Order;
|
token.ThrowIfCancellationRequested();
|
||||||
return attempt;
|
var frame = stack.Peek();
|
||||||
|
var node = frame.Node;
|
||||||
|
if (node.Order.Length == snapshot.Placements.Count)
|
||||||
|
{
|
||||||
|
attempt.Order = node.Order;
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
if (attempt.Advance(node))
|
||||||
|
progressExpansions = Expansions;
|
||||||
|
else if (stall is int limit && Expansions - progressExpansions > limit)
|
||||||
|
return attempt;
|
||||||
|
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly).GetEnumerator();
|
||||||
|
if (!frame.Children.MoveNext())
|
||||||
|
{
|
||||||
|
stack.Pop().Children.Dispose();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stack.Push(new(frame.Children.Current.Node));
|
||||||
}
|
}
|
||||||
if (attempt.Advance(node))
|
return attempt;
|
||||||
progressExpansions = Expansions;
|
}
|
||||||
else if (stall is int limit && Expansions - progressExpansions > limit)
|
finally
|
||||||
return attempt;
|
{
|
||||||
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly);
|
// Ready, stalled, cancelled and budget-exhausted searches can all leave
|
||||||
if (frame.Next == frame.Children.Length)
|
// suspended siblings. Release their captured prefixes on every exit.
|
||||||
{
|
foreach (var frame in stack)
|
||||||
stack.Pop();
|
frame.Children?.Dispose();
|
||||||
continue;
|
|
||||||
}
|
|
||||||
stack.Push(new(frame.Children[frame.Next++].Node));
|
|
||||||
}
|
}
|
||||||
return attempt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -259,8 +269,14 @@ internal static class JointCuttingPlanSearch
|
|||||||
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
|
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
|
||||||
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
|
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
|
private IEnumerable<Edge> OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
|
||||||
{
|
{
|
||||||
|
// A prescribed next part (or an active part's next contour) has a single
|
||||||
|
// source. Expand already yields its contour/entry rank order, so emit and
|
||||||
|
// validate a sibling only when DFS reaches it. Free whole-part selection
|
||||||
|
// still needs every source's distances before it can rank them.
|
||||||
|
if (sequence != null || node.Active != null)
|
||||||
|
return Expand(node, sequence, attempt, preferredOnly);
|
||||||
var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
|
var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
|
||||||
if (edges.Count <= 1)
|
if (edges.Count <= 1)
|
||||||
return edges.ToArray();
|
return edges.ToArray();
|
||||||
@@ -334,8 +350,10 @@ internal static class JointCuttingPlanSearch
|
|||||||
// First try one ranked hole chain per endpoint. A later-part failure then
|
// First try one ranked hole chain per endpoint. A later-part failure then
|
||||||
// changes the endpoint before replaying all earlier hole combinations.
|
// changes the endpoint before replaying all earlier hole combinations.
|
||||||
// The retained pass below still searches every entry and hole order.
|
// The retained pass below still searches every entry and hole order.
|
||||||
|
if (preference != null)
|
||||||
|
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).ThenBy(c => c);
|
||||||
if (preferredOnly && preference != null)
|
if (preferredOnly && preference != null)
|
||||||
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1);
|
contours = contours.Take(1);
|
||||||
foreach (var contour in contours)
|
foreach (var contour in contours)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -593,7 +611,6 @@ internal static class JointCuttingPlanSearch
|
|||||||
private sealed class Frame(Node node)
|
private sealed class Frame(Node node)
|
||||||
{
|
{
|
||||||
internal Node Node { get; } = node;
|
internal Node Node { get; } = node;
|
||||||
internal Edge[] Children { get; set; }
|
internal IEnumerator<Edge> Children { get; set; }
|
||||||
internal int Next { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +290,63 @@ namespace OpenNest.Engine.Fill
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settles a copied placement against the plate in both axis orders, choosing
|
||||||
|
/// the group closest to the quadrant's work-area corner. A coarse box pass
|
||||||
|
/// runs only when no moving box starts inside an existing part's box.
|
||||||
|
/// </summary>
|
||||||
|
public static void SettlePlacement(
|
||||||
|
List<Part> movingParts,
|
||||||
|
Plate plate,
|
||||||
|
PushDirection horizontal,
|
||||||
|
PushDirection vertical,
|
||||||
|
int maxIterations = 20
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (movingParts.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var workArea = plate.WorkArea();
|
||||||
|
var skipBoxes = movingParts.Any(moving =>
|
||||||
|
plate.Parts.Any(obstacle => moving.BoundingBox.Intersects(obstacle.BoundingBox))
|
||||||
|
);
|
||||||
|
var bestScore = double.MaxValue;
|
||||||
|
Vector[] best = null;
|
||||||
|
|
||||||
|
foreach (var first in new[] { horizontal, vertical })
|
||||||
|
{
|
||||||
|
var second = first == horizontal ? vertical : horizontal;
|
||||||
|
var trial = movingParts.Select(p => (Part)p.Clone()).ToList();
|
||||||
|
if (!skipBoxes)
|
||||||
|
{
|
||||||
|
PushBoundingBox(trial, plate, first);
|
||||||
|
PushBoundingBox(trial, plate, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < maxIterations; i++)
|
||||||
|
{
|
||||||
|
var moved = Push(trial, plate, first) + Push(trial, plate, second);
|
||||||
|
if (moved < 0.01)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bounds = trial.GetBoundingBox();
|
||||||
|
var dx = horizontal == PushDirection.Left
|
||||||
|
? bounds.Left - workArea.Left : workArea.Right - bounds.Right;
|
||||||
|
var dy = vertical == PushDirection.Down
|
||||||
|
? bounds.Bottom - workArea.Bottom : workArea.Top - bounds.Top;
|
||||||
|
var score = dx * dx + dy * dy;
|
||||||
|
if (score < bestScore)
|
||||||
|
{
|
||||||
|
bestScore = score;
|
||||||
|
best = trial.Select(p => p.Location).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < movingParts.Count; i++)
|
||||||
|
movingParts[i].Location = best[i];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Repeatedly pushes parts left then down until total movement per
|
/// Repeatedly pushes parts left then down until total movement per
|
||||||
/// iteration falls below the given threshold.
|
/// iteration falls below the given threshold.
|
||||||
|
|||||||
@@ -22,20 +22,24 @@ namespace OpenNest.Engine.Jobs.Cutouts;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement.
|
/// Suited to many small copies in a large cutout; a few large inserts belong to NFP placement.
|
||||||
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Not wired into
|
/// Poses are in the frame's own coordinates: frame at the origin, unrotated. Used by the
|
||||||
/// any engine or pipeline yet: placing parts in cutouts waits on containment-aware cutting order.
|
/// internal pipeline cutout preview, but not enabled for normal whole-job calls;
|
||||||
|
/// production placement waits on cutting/post safety.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static class CutoutLatticeFill
|
internal static class CutoutLatticeFill
|
||||||
{
|
{
|
||||||
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
|
/// <summary>Offsets tried per side on each axis; the search covers (2n + 1)^2 offsets.</summary>
|
||||||
internal const int DefaultShiftSteps = 8;
|
internal const int DefaultShiftSteps = 8;
|
||||||
|
|
||||||
private const double FlattenTolerance = 0.001;
|
internal const double FlattenTolerance = 0.001;
|
||||||
|
|
||||||
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
|
/// <summary>Extra growth beyond the spacing, covering flattening and Clipper rounding.</summary>
|
||||||
private const double Margin = FlattenTolerance + 0.001;
|
private const double Margin = FlattenTolerance + 0.001;
|
||||||
|
|
||||||
private const int Precision = NestTolerances.ClipperPrecision;
|
private const int Precision = NestTolerances.ClipperPrecision;
|
||||||
|
// Fill materializes an entire grid before quantity trimming. Bound work even for a
|
||||||
|
// tiny insert with demand of only three, rather than allocating millions of clones.
|
||||||
|
private const int MaxLatticePositions = 1000;
|
||||||
|
|
||||||
/// <summary>Returns up to <paramref name="maxQuantity"/> insert poses inside the cutout,
|
/// <summary>Returns up to <paramref name="maxQuantity"/> insert poses inside the cutout,
|
||||||
/// or none when no copy fits.</summary>
|
/// or none when no copy fits.</summary>
|
||||||
@@ -45,9 +49,17 @@ internal static class CutoutLatticeFill
|
|||||||
|
|
||||||
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||||
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token)
|
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps, CancellationToken token)
|
||||||
|
=> Fill(frame, cutoutIndex, insert, maxQuantity, spacing, shiftSteps,
|
||||||
|
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token);
|
||||||
|
|
||||||
|
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||||
|
NestJobPart insert, int maxQuantity, double spacing, int shiftSteps,
|
||||||
|
IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, CancellationToken token)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(frame);
|
ArgumentNullException.ThrowIfNull(frame);
|
||||||
ArgumentNullException.ThrowIfNull(insert);
|
ArgumentNullException.ThrowIfNull(insert);
|
||||||
|
ArgumentNullException.ThrowIfNull(occupied);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps);
|
ArgumentOutOfRangeException.ThrowIfNegative(shiftSteps);
|
||||||
if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0)
|
if (maxQuantity <= 0 || !double.IsFinite(spacing) || spacing < 0)
|
||||||
return Array.Empty<NestJobPlacement>();
|
return Array.Empty<NestJobPlacement>();
|
||||||
@@ -69,6 +81,10 @@ internal static class CutoutLatticeFill
|
|||||||
var bounds = cutout.BoundingBox;
|
var bounds = cutout.BoundingBox;
|
||||||
var insertBounds = insertGeometry.Bounds;
|
var insertBounds = insertGeometry.Bounds;
|
||||||
var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing;
|
var step = System.Math.Max(insertBounds.Length, insertBounds.Width) + spacing;
|
||||||
|
var estimated = (System.Math.Ceiling(bounds.Length / step) + 4)
|
||||||
|
* (System.Math.Ceiling(bounds.Width / step) + 4);
|
||||||
|
if (!(step > 0) || !double.IsFinite(estimated) || estimated > MaxLatticePositions)
|
||||||
|
return Array.Empty<NestJobPlacement>();
|
||||||
|
|
||||||
var lattice = Lattice(insert, bounds, step, spacing, token);
|
var lattice = Lattice(insert, bounds, step, spacing, token);
|
||||||
if (lattice.Count == 0)
|
if (lattice.Count == 0)
|
||||||
@@ -83,7 +99,8 @@ internal static class CutoutLatticeFill
|
|||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var (dx, dy) = Shift(step, shiftSteps, i, j);
|
var (dx, dy) = Shift(step, shiftSteps, i, j);
|
||||||
var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy));
|
var count = lattice.Count(p => Inside(regions[p.Rotation], p.X + dx, p.Y + dy)
|
||||||
|
&& ClearsOccupied(p, dx, dy));
|
||||||
if (Better(count, i, j, best))
|
if (Better(count, i, j, best))
|
||||||
best = (count, i, j);
|
best = (count, i, j);
|
||||||
}
|
}
|
||||||
@@ -91,13 +108,33 @@ internal static class CutoutLatticeFill
|
|||||||
return Array.Empty<NestJobPlacement>();
|
return Array.Empty<NestJobPlacement>();
|
||||||
|
|
||||||
var (sx, sy) = Shift(step, shiftSteps, best.I, best.J);
|
var (sx, sy) = Shift(step, shiftSteps, best.I, best.J);
|
||||||
var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy))
|
var kept = lattice.Where(p => Inside(regions[p.Rotation], p.X + sx, p.Y + sy)
|
||||||
|
&& ClearsOccupied(p, sx, sy))
|
||||||
.Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8),
|
.Select(p => new NestJobPlacement(insert.Id, 0, System.Math.Round(p.X + sx, 8),
|
||||||
System.Math.Round(p.Y + sy, 8), p.Rotation))
|
System.Math.Round(p.Y + sy, 8), p.Rotation))
|
||||||
.OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation)
|
.OrderBy(p => p.Y).ThenBy(p => p.X).ThenBy(p => p.Rotation)
|
||||||
.ToList();
|
.ToList();
|
||||||
return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity)
|
return Certify(frame.Id, frameGeometry, insertGeometry, kept, spacing, token).Take(maxQuantity)
|
||||||
.Select((p, index) => p with { InstanceIndex = index }).ToArray();
|
.Select((p, index) => p with { InstanceIndex = index }).ToArray();
|
||||||
|
|
||||||
|
bool ClearsOccupied(NestJobPlacement pose, double dx, double dy)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (occupied.Count == 0)
|
||||||
|
return true;
|
||||||
|
var moved = pose with
|
||||||
|
{
|
||||||
|
X = System.Math.Round(pose.X + dx, 8),
|
||||||
|
Y = System.Math.Round(pose.Y + dy, 8)
|
||||||
|
};
|
||||||
|
foreach (var other in occupied)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (!NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, moved, spacing))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Fill over the cutout's bounds grown by one step on every side, in frame
|
/// <summary>Fill over the cutout's bounds grown by one step on every side, in frame
|
||||||
@@ -134,7 +171,7 @@ internal static class CutoutLatticeFill
|
|||||||
/// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline
|
/// cutout: some point b0 of the grown outline is inside (t in hole - b0) and the grown outline
|
||||||
/// never meets the cutout boundary (t outside boundary + reflected outline).
|
/// never meets the cutout boundary (t outside boundary + reflected outline).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
|
internal static PathsD InnerFit(JobPartGeometry insert, double rotation, PathD hole, double spacing)
|
||||||
{
|
{
|
||||||
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
|
var entities = insert.Perimeter.Entities.Select(e => e.Clone()).ToList();
|
||||||
foreach (var entity in entities)
|
foreach (var entity in entities)
|
||||||
@@ -203,7 +240,7 @@ internal static class CutoutLatticeFill
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
|
/// <summary>Strictly inside an even-odd region; boundary points count as outside.</summary>
|
||||||
private static bool Inside(PathsD region, double x, double y)
|
internal static bool Inside(PathsD region, double x, double y)
|
||||||
{
|
{
|
||||||
var point = new PointD(x, y);
|
var point = new PointD(x, y);
|
||||||
var inside = false;
|
var inside = false;
|
||||||
@@ -218,7 +255,7 @@ internal static class CutoutLatticeFill
|
|||||||
return inside;
|
return inside;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PathD Positive(PathD path)
|
internal static PathD Positive(PathD path)
|
||||||
{
|
{
|
||||||
if (path.Count >= 3 && !Clipper.IsPositive(path))
|
if (path.Count >= 3 && !Clipper.IsPositive(path))
|
||||||
path.Reverse();
|
path.Reverse();
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using Clipper2Lib;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
|
||||||
|
/// <summary>Test-only single-insert inner-fit proposal; never changes a job or a live plate.</summary>
|
||||||
|
internal static class CutoutNfpProposal
|
||||||
|
{
|
||||||
|
// Fixed chord tolerance in the shared round offset otherwise makes extremely large
|
||||||
|
// spacing values allocate an unbounded number of arc vertices. This conservative
|
||||||
|
// test-only kernel declines scales it cannot process safely.
|
||||||
|
private const double MaxCoordinate = 1e8;
|
||||||
|
private const double MaxSpacing = 100;
|
||||||
|
|
||||||
|
/// <summary>Returns one frame-local pose inside a closed cutout, or null if no pose is certified.
|
||||||
|
/// A null result is not a proof that the insert cannot fit elsewhere in this cutout.</summary>
|
||||||
|
internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert,
|
||||||
|
double spacing, CancellationToken token = default) =>
|
||||||
|
Find(frame, cutoutIndex, insert, spacing,
|
||||||
|
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), token);
|
||||||
|
|
||||||
|
/// <summary>Find a pose clearing already accepted inserts as well as the enclosing frame.</summary>
|
||||||
|
internal static NestJobPlacement? Find(NestJobPart frame, int cutoutIndex, NestJobPart insert,
|
||||||
|
double spacing, IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(frame);
|
||||||
|
ArgumentNullException.ThrowIfNull(insert);
|
||||||
|
ArgumentNullException.ThrowIfNull(occupied);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (!double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing)
|
||||||
|
return null;
|
||||||
|
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
|
||||||
|
var insertGeometry = JobPartGeometry.TryRead(insert.Geometry);
|
||||||
|
if (frameGeometry == null || insertGeometry == null
|
||||||
|
|| !WithinRange(frameGeometry.Bounds) || !WithinRange(insertGeometry.Bounds))
|
||||||
|
return null;
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
|
||||||
|
var cutout = frameGeometry.Cutouts[cutoutIndex];
|
||||||
|
if (!cutout.IsClosed() || !WithinRange(cutout.BoundingBox)
|
||||||
|
|| spacing * 2 >= System.Math.Min(cutout.BoundingBox.Length, cutout.BoundingBox.Width))
|
||||||
|
return null;
|
||||||
|
var hole = CutoutLatticeFill.Positive(ClipperBridge.ToPath(
|
||||||
|
ClipperBridge.Flatten(cutout, CutoutLatticeFill.FlattenTolerance, circumscribe: false),
|
||||||
|
new Vector()));
|
||||||
|
if (hole.Count < 3)
|
||||||
|
return null;
|
||||||
|
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
||||||
|
foreach (var rotation in insert.Rotation.EnumerateAngles())
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var region = CutoutLatticeFill.InnerFit(insertGeometry, rotation, hole, spacing);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
foreach (var (x, y) in Candidates(region))
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (!CutoutLatticeFill.Inside(region, x, y))
|
||||||
|
continue;
|
||||||
|
var pose = new NestJobPlacement(insert.Id, 0, x, y, rotation);
|
||||||
|
if (!NestLayoutCheck.Clears(frameGeometry, framePose, insertGeometry, pose, spacing))
|
||||||
|
continue;
|
||||||
|
var clears = true;
|
||||||
|
foreach (var other in occupied)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (NestLayoutCheck.Clears(other.Geometry, other.Pose, insertGeometry, pose, spacing))
|
||||||
|
continue;
|
||||||
|
clears = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (clears)
|
||||||
|
return pose;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool WithinRange(Box box) =>
|
||||||
|
double.IsFinite(box.Left) && double.IsFinite(box.Right)
|
||||||
|
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
|
||||||
|
&& System.Math.Abs(box.Left) <= MaxCoordinate && System.Math.Abs(box.Right) <= MaxCoordinate
|
||||||
|
&& System.Math.Abs(box.Bottom) <= MaxCoordinate && System.Math.Abs(box.Top) <= MaxCoordinate;
|
||||||
|
|
||||||
|
/// <summary>Sample component interiors, then points just inside each boundary toward
|
||||||
|
/// its component's center. This bounded search is not a complete inner-fit solver.</summary>
|
||||||
|
private static IEnumerable<(double X, double Y)> Candidates(PathsD region)
|
||||||
|
{
|
||||||
|
foreach (var path in region.Where(p => p.Count >= 3 && Clipper.IsPositive(p)))
|
||||||
|
{
|
||||||
|
var bounds = Clipper.GetBounds(new PathsD { path });
|
||||||
|
var cx = (bounds.left + bounds.right) / 2;
|
||||||
|
var cy = (bounds.top + bounds.bottom) / 2;
|
||||||
|
yield return (cx, cy);
|
||||||
|
foreach (var point in path)
|
||||||
|
yield return (point.x * 0.99 + cx * 0.01, point.y * 0.99 + cy * 0.01);
|
||||||
|
// Bounded interior samples let a residual insert land beside a partial lattice;
|
||||||
|
// a center/vertex-only sample often lands directly on an earlier copy.
|
||||||
|
for (var y = 1; y <= 9; y++)
|
||||||
|
for (var x = 1; x <= 9; x++)
|
||||||
|
yield return (bounds.left + (bounds.right - bounds.left) * x / 10,
|
||||||
|
bounds.top + (bounds.bottom - bounds.top) * y / 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
|
||||||
|
/// <summary>Internal-only frame bundles. No normal pipeline caller enables this.</summary>
|
||||||
|
internal sealed class CutoutPipelinePrepass
|
||||||
|
{
|
||||||
|
// A job with thousands of repeats still offers the residual as ordinary demand;
|
||||||
|
// do not multiply expensive Clipper/Fill preparation by every physical instance.
|
||||||
|
private const int MaxBundlesPerJob = 32;
|
||||||
|
private sealed record Bundle(string FrameId, IReadOnlyList<NestJobPlacement> LocalInserts);
|
||||||
|
private readonly IReadOnlyDictionary<string, Bundle> bundles;
|
||||||
|
|
||||||
|
private CutoutPipelinePrepass(NestJob engineJob, IReadOnlyDictionary<string, Bundle> bundles)
|
||||||
|
{
|
||||||
|
EngineJob = engineJob;
|
||||||
|
this.bundles = bundles;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal NestJob EngineJob { get; }
|
||||||
|
|
||||||
|
internal static CutoutPipelinePrepass? Prepare(NestJob original, CancellationToken token)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (original.Plates.Count == 0)
|
||||||
|
return null;
|
||||||
|
// One reservation vector works for every offered stock. Current jobs cannot
|
||||||
|
// represent stock-conditional insert quantities; use the largest spacing.
|
||||||
|
var spacing = original.Plates.Max(p => p.PartSpacing);
|
||||||
|
var remaining = original.Parts.ToDictionary(p => p.Id, p => p.Quantity);
|
||||||
|
var geometry = original.Parts.ToDictionary(p => p.Id, p => JobPartGeometry.TryRead(p.Geometry));
|
||||||
|
var bundles = new Dictionary<string, Bundle>(StringComparer.Ordinal);
|
||||||
|
var byFrame = new Dictionary<string, List<NestJobPart>>(StringComparer.Ordinal);
|
||||||
|
foreach (var frame in original.Parts)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (bundles.Count >= MaxBundlesPerJob)
|
||||||
|
break;
|
||||||
|
var profile = geometry[frame.Id];
|
||||||
|
if (profile == null || profile.Cutouts.Count == 0)
|
||||||
|
continue;
|
||||||
|
var proxies = new List<NestJobPart>();
|
||||||
|
byFrame[frame.Id] = proxies;
|
||||||
|
for (var instance = 0; instance < frame.Quantity; instance++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (bundles.Count >= MaxBundlesPerJob)
|
||||||
|
break;
|
||||||
|
IReadOnlyList<NestJobPlacement>? chosen = null;
|
||||||
|
double frameAngle = 0;
|
||||||
|
// Outer-perimeter symmetry cannot deduplicate asymmetric cutouts.
|
||||||
|
foreach (var angle in frame.Rotation.EnumerateAngles(maxSamples: 16))
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (!FitsOfferedStock(frame, angle, original.Plates))
|
||||||
|
continue;
|
||||||
|
var local = new List<NestJobPlacement>();
|
||||||
|
var available = new Dictionary<string, int>(remaining);
|
||||||
|
for (var hole = 0; hole < profile.Cutouts.Count; hole++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
// Flat bundles: frames cannot simultaneously be inserts in another bundle.
|
||||||
|
var candidates = original.Parts
|
||||||
|
.Where(p => p.Id != frame.Id && available[p.Id] > 0
|
||||||
|
&& p.Priority == frame.Priority
|
||||||
|
&& geometry[p.Id] is { Cutouts.Count: 0 })
|
||||||
|
.Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id],
|
||||||
|
p.Priority, LocalRotation(p.Rotation, angle)))
|
||||||
|
.ToArray();
|
||||||
|
if (candidates.Length == 0)
|
||||||
|
break;
|
||||||
|
foreach (var pose in CutoutRouter.Fill(frame, hole, candidates, spacing, token))
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var source = geometry[pose.PartId]!;
|
||||||
|
var policy = original.Parts.Single(p => p.Id == pose.PartId).Rotation;
|
||||||
|
if (!policy.Allows(angle + pose.Rotation)
|
||||||
|
|| local.Any(other => !NestLayoutCheck.Clears(
|
||||||
|
geometry[other.PartId]!, other, source, pose, spacing)))
|
||||||
|
continue;
|
||||||
|
local.Add(pose);
|
||||||
|
available[pose.PartId]--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (local.Count <= (chosen?.Count ?? 0))
|
||||||
|
continue;
|
||||||
|
chosen = local.ToArray();
|
||||||
|
frameAngle = angle;
|
||||||
|
}
|
||||||
|
if (chosen == null || chosen.Count == 0)
|
||||||
|
break;
|
||||||
|
var proxyId = $"__cutout-proxy-{frame.Id}-{instance}";
|
||||||
|
if (remaining.ContainsKey(proxyId) || bundles.ContainsKey(proxyId))
|
||||||
|
throw new InvalidOperationException("Cutout proxy ID collides with a requirement.");
|
||||||
|
bundles.Add(proxyId, new Bundle(frame.Id, chosen));
|
||||||
|
proxies.Add(new NestJobPart(proxyId, frame.Geometry, 1, frame.Priority,
|
||||||
|
RotationPolicy.Fixed(frameAngle)));
|
||||||
|
remaining[frame.Id]--;
|
||||||
|
foreach (var pose in chosen)
|
||||||
|
remaining[pose.PartId]--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bundles.Count == 0)
|
||||||
|
return null;
|
||||||
|
var transformed = new List<NestJobPart>();
|
||||||
|
foreach (var part in original.Parts)
|
||||||
|
{
|
||||||
|
if (byFrame.TryGetValue(part.Id, out var proxies))
|
||||||
|
transformed.AddRange(proxies);
|
||||||
|
if (remaining[part.Id] > 0)
|
||||||
|
transformed.Add(new NestJobPart(part.Id, part.Geometry, remaining[part.Id],
|
||||||
|
part.Priority, part.Rotation));
|
||||||
|
}
|
||||||
|
return new CutoutPipelinePrepass(new NestJob(transformed, original.Plates,
|
||||||
|
original.Options), bundles);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RotationPolicy LocalRotation(RotationPolicy original, double frameAngle) =>
|
||||||
|
original.Kind switch
|
||||||
|
{
|
||||||
|
RotationPolicyKind.Automatic => RotationPolicy.Automatic,
|
||||||
|
RotationPolicyKind.Fixed => RotationPolicy.Fixed(original.Start - frameAngle,
|
||||||
|
original.Allow180Equivalent),
|
||||||
|
_ => RotationPolicy.BoundedSweep(original.Start - frameAngle,
|
||||||
|
original.End - frameAngle, original.Step, original.Allow180Equivalent),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool FitsOfferedStock(NestJobPart frame, double angle,
|
||||||
|
IReadOnlyList<NestPlateStock> stock)
|
||||||
|
{
|
||||||
|
var part = new Part(DrawingJobMapper.CreateDrawing(frame));
|
||||||
|
part.Rotate(angle);
|
||||||
|
var bounds = NestLayoutCheck.MaterialBounds(part);
|
||||||
|
return stock.Any(s => s.Quantity != 0 && s.Fits(bounds.Length, bounds.Width,
|
||||||
|
NestTolerances.WorkAreaSlack));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reject untrusted accounting before expansion. A failed trial is never bindable,
|
||||||
|
/// including with allowInvalid. Only actually placed proxies consume reserved inserts.</summary>
|
||||||
|
internal bool TryExpand(NestJob original, NestJobResult result, CancellationToken token,
|
||||||
|
out NestJobResult? expanded,
|
||||||
|
out string failure)
|
||||||
|
{
|
||||||
|
expanded = null;
|
||||||
|
failure = "Invalid cutout engine accounting";
|
||||||
|
var parts = EngineJob.Parts.ToDictionary(p => p.Id);
|
||||||
|
var counts = parts.ToDictionary(p => p.Key, _ => 0);
|
||||||
|
var indices = parts.ToDictionary(p => p.Key, _ => new HashSet<int>());
|
||||||
|
var stockById = EngineJob.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal);
|
||||||
|
var used = stockById.ToDictionary(s => s.Key, _ => 0, StringComparer.Ordinal);
|
||||||
|
var expectedIndex = 0;
|
||||||
|
foreach (var sheet in result.Plates)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (sheet.PlateIndex != expectedIndex++ || sheet.Placements.Count == 0
|
||||||
|
|| !used.ContainsKey(sheet.StockId))
|
||||||
|
return false;
|
||||||
|
used[sheet.StockId]++;
|
||||||
|
foreach (var pose in sheet.Placements)
|
||||||
|
{
|
||||||
|
if (pose.PartId == null || !parts.ContainsKey(pose.PartId)
|
||||||
|
|| pose.InstanceIndex < 0 || !indices[pose.PartId].Add(pose.InstanceIndex)
|
||||||
|
|| ++counts[pose.PartId] > parts[pose.PartId].Quantity)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (var row in indices)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (row.Value.Any(index => index >= counts[row.Key]))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (result.Fulfillment.Count != parts.Count || result.StockUsage.Count != used.Count)
|
||||||
|
return false;
|
||||||
|
var seenParts = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var row in result.Fulfillment)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (row.PartId == null || !parts.TryGetValue(row.PartId, out var part)
|
||||||
|
|| !seenParts.Add(row.PartId)
|
||||||
|
|| row.Requested != part.Quantity || row.Placed != counts[row.PartId]
|
||||||
|
|| row.Unplaced != part.Quantity - counts[row.PartId])
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var seenStock = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var row in result.StockUsage)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (row.StockId == null || !stockById.TryGetValue(row.StockId, out var stock)
|
||||||
|
|| !seenStock.Add(row.StockId)
|
||||||
|
|| row.Used != used[row.StockId]
|
||||||
|
|| row.Remaining != stock.Quantity - row.Used)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var complete = parts.Values.All(p => counts[p.Id] == p.Quantity);
|
||||||
|
if (result.Status != (complete ? NestJobStatus.Complete : NestJobStatus.Incomplete)
|
||||||
|
|| (complete && result.StopReason != NestJobStopReason.Completed)
|
||||||
|
|| (!complete && result.StopReason == NestJobStopReason.Completed))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var builder = new NestJobResultBuilder(original);
|
||||||
|
foreach (var sheet in result.Plates)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var poses = new List<(string PartId, double X, double Y, double Rotation)>();
|
||||||
|
foreach (var pose in sheet.Placements)
|
||||||
|
{
|
||||||
|
if (!bundles.TryGetValue(pose.PartId, out var bundle))
|
||||||
|
{
|
||||||
|
poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
poses.Add((bundle.FrameId, pose.X, pose.Y, pose.Rotation));
|
||||||
|
var sine = System.Math.Sin(pose.Rotation);
|
||||||
|
var cosine = System.Math.Cos(pose.Rotation);
|
||||||
|
foreach (var local in bundle.LocalInserts)
|
||||||
|
poses.Add((local.PartId,
|
||||||
|
pose.X + local.X * cosine - local.Y * sine,
|
||||||
|
pose.Y + local.X * sine + local.Y * cosine,
|
||||||
|
pose.Rotation + local.Rotation));
|
||||||
|
}
|
||||||
|
// Canonical stock reference, not an engine-supplied object with altered settings.
|
||||||
|
var stock = original.Plates.Single(s => s.Id == sheet.StockId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
builder.AddSheet(stock, poses);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
expanded = builder.Build(result.StopReason);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
|
||||||
|
/// <summary>Internal frame-local cutout proposal. Never changes demand, stock or a live plate.</summary>
|
||||||
|
/// <remarks>Area ratio selects search order, not placement permission. The small-insert path
|
||||||
|
/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search;
|
||||||
|
/// the large-insert path tries NFP first. All accepted poses are clearance-checked.
|
||||||
|
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.</remarks>
|
||||||
|
internal static class CutoutRouter
|
||||||
|
{
|
||||||
|
// Calibrated as search-order hints, not hard geometry cutoffs. Round-hole neutral tests
|
||||||
|
// show a Fill win at 0.204, so the original proposed >0.20 NFP-only rule loses copies.
|
||||||
|
internal const double SmallRatio = 0.10;
|
||||||
|
internal const double LargeRatio = 0.35;
|
||||||
|
|
||||||
|
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||||
|
IReadOnlyList<NestJobPart> inserts, double spacing, CancellationToken token = default) =>
|
||||||
|
Fill(frame, cutoutIndex, inserts, spacing, null, token);
|
||||||
|
|
||||||
|
/// <summary>Optional internal diagnostic receives only poses from the chosen proposal,
|
||||||
|
/// after indexing; true marks a retained lattice seed, false a residual NFP pose.</summary>
|
||||||
|
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
|
||||||
|
IReadOnlyList<NestJobPart> inserts, double spacing,
|
||||||
|
Action<NestJobPlacement, bool>? observeAccepted, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(frame);
|
||||||
|
ArgumentNullException.ThrowIfNull(inserts);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (!double.IsFinite(spacing) || spacing < 0 || spacing > 100)
|
||||||
|
return Array.Empty<NestJobPlacement>();
|
||||||
|
var frameGeometry = JobPartGeometry.TryRead(frame.Geometry);
|
||||||
|
if (frameGeometry == null)
|
||||||
|
return Array.Empty<NestJobPlacement>();
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
|
||||||
|
var hole = frameGeometry.Cutouts[cutoutIndex];
|
||||||
|
if (!hole.IsClosed() || !Supported(frameGeometry.Bounds) || !Supported(hole.BoundingBox))
|
||||||
|
return Array.Empty<NestJobPlacement>();
|
||||||
|
if (inserts.Any(p => p == null || !SupportedGeometry(p)))
|
||||||
|
return Array.Empty<NestJobPlacement>();
|
||||||
|
if (inserts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != inserts.Count
|
||||||
|
|| inserts.Any(p => p.Id == frame.Id))
|
||||||
|
throw new ArgumentException("Requirement IDs must be distinct from the frame and each other.",
|
||||||
|
nameof(inserts));
|
||||||
|
|
||||||
|
var accepted = new List<NestJobPlacement>();
|
||||||
|
var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
|
||||||
|
var holeArea = System.Math.Abs(hole.Area());
|
||||||
|
// Preserve caller requirement order: priorities are a whole-job concern, not inferred here.
|
||||||
|
foreach (var part in inserts)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var geometry = JobPartGeometry.Read(part.Geometry);
|
||||||
|
var ratio = geometry.MaterialArea / holeArea;
|
||||||
|
// Unsupported or degenerate material cannot establish a useful routing ratio;
|
||||||
|
// NFP retains its own fail-closed checks and the bounded lattice is not tried.
|
||||||
|
var tryLattice = part.Quantity >= 3 && double.IsFinite(ratio) && ratio > 0;
|
||||||
|
var nfpFirst = !tryLattice || ratio >= LargeRatio;
|
||||||
|
var compareBoth = tryLattice && ratio >= SmallRatio && ratio < LargeRatio;
|
||||||
|
var nfp = nfpFirst ? Nfp(Array.Empty<NestJobPlacement>()) : null;
|
||||||
|
var lattice = tryLattice && (!nfpFirst || nfp!.Count < part.Quantity)
|
||||||
|
? CutoutLatticeFill.Fill(frame, cutoutIndex, part, part.Quantity, spacing,
|
||||||
|
CutoutLatticeFill.DefaultShiftSteps, occupied, token)
|
||||||
|
: Array.Empty<NestJobPlacement>();
|
||||||
|
IReadOnlyList<NestJobPlacement> chosen;
|
||||||
|
var retainedLattice = 0;
|
||||||
|
if (compareBoth || (nfpFirst && lattice.Count > 0))
|
||||||
|
{
|
||||||
|
nfp ??= Nfp(Array.Empty<NestJobPlacement>());
|
||||||
|
var fromLattice = Complete(lattice);
|
||||||
|
chosen = fromLattice.Count > nfp.Count ? fromLattice : nfp;
|
||||||
|
if (ReferenceEquals(chosen, fromLattice))
|
||||||
|
retainedLattice = lattice.Count;
|
||||||
|
}
|
||||||
|
else if (nfpFirst)
|
||||||
|
chosen = nfp!;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// A single Fill copy ties one NFP pose; prefer NFP's stable candidate
|
||||||
|
// order, but retain the lattice copy if bounded NFP found nothing.
|
||||||
|
var seed = lattice.Count > 1 ? lattice : Array.Empty<NestJobPlacement>();
|
||||||
|
chosen = Complete(seed);
|
||||||
|
if (chosen.Count == 0 && lattice.Count == 1)
|
||||||
|
chosen = lattice;
|
||||||
|
retainedLattice = ReferenceEquals(chosen, lattice) ? lattice.Count : seed.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
var localIndex = 0;
|
||||||
|
foreach (var pose in chosen)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var indexed = pose with { InstanceIndex = accepted.Count(p => p.PartId == part.Id) };
|
||||||
|
accepted.Add(indexed);
|
||||||
|
occupied.Add((geometry, indexed));
|
||||||
|
observeAccepted?.Invoke(indexed, localIndex++ < retainedLattice);
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<NestJobPlacement> Complete(IReadOnlyList<NestJobPlacement> seed)
|
||||||
|
{
|
||||||
|
if (seed.Count >= part.Quantity)
|
||||||
|
return seed;
|
||||||
|
var result = new List<NestJobPlacement>(seed);
|
||||||
|
result.AddRange(Nfp(seed));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<NestJobPlacement> Nfp(IReadOnlyList<NestJobPlacement> seed)
|
||||||
|
{
|
||||||
|
var result = new List<NestJobPlacement>();
|
||||||
|
var local = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>(occupied);
|
||||||
|
local.AddRange(seed.Select(p => (geometry, p)));
|
||||||
|
while (seed.Count + result.Count < part.Quantity)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var pose = CutoutNfpProposal.Find(frame, cutoutIndex, part, spacing, local, token);
|
||||||
|
if (pose == null)
|
||||||
|
break;
|
||||||
|
result.Add(pose);
|
||||||
|
local.Add((geometry, pose));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
return accepted.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SupportedGeometry(NestJobPart part)
|
||||||
|
{
|
||||||
|
var geometry = JobPartGeometry.TryRead(part.Geometry);
|
||||||
|
return geometry != null && Supported(geometry.Bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Supported(OpenNest.Geometry.Box box) =>
|
||||||
|
double.IsFinite(box.Left) && double.IsFinite(box.Right)
|
||||||
|
&& double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
|
||||||
|
&& System.Math.Abs(box.Left) <= 1e8 && System.Math.Abs(box.Right) <= 1e8
|
||||||
|
&& System.Math.Abs(box.Bottom) <= 1e8 && System.Math.Abs(box.Top) <= 1e8;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using System.Diagnostics;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest.Engine.Jobs.Adapters;
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
|
||||||
namespace OpenNest.Engine.Jobs;
|
namespace OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
@@ -36,7 +37,8 @@ public sealed class NestPipelineResult
|
|||||||
IReadOnlyList<string> violations,
|
IReadOnlyList<string> violations,
|
||||||
bool canKeep,
|
bool canKeep,
|
||||||
TimeSpan solveTime,
|
TimeSpan solveTime,
|
||||||
TimeSpan validationTime
|
TimeSpan validationTime,
|
||||||
|
IReadOnlyDictionary<string, Drawing> drawingsByPartId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
EngineName = engineName;
|
EngineName = engineName;
|
||||||
@@ -47,6 +49,7 @@ public sealed class NestPipelineResult
|
|||||||
CanKeep = canKeep;
|
CanKeep = canKeep;
|
||||||
SolveTime = solveTime;
|
SolveTime = solveTime;
|
||||||
ValidationTime = validationTime;
|
ValidationTime = validationTime;
|
||||||
|
DrawingsByPartId = drawingsByPartId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string EngineName { get; }
|
public string EngineName { get; }
|
||||||
@@ -62,6 +65,7 @@ public sealed class NestPipelineResult
|
|||||||
public NestJobStopReason StopReason => Raw.StopReason;
|
public NestJobStopReason StopReason => Raw.StopReason;
|
||||||
public TimeSpan SolveTime { get; }
|
public TimeSpan SolveTime { get; }
|
||||||
public TimeSpan ValidationTime { get; }
|
public TimeSpan ValidationTime { get; }
|
||||||
|
internal IReadOnlyDictionary<string, Drawing> DrawingsByPartId { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -72,6 +76,15 @@ public sealed class NestPipelineResult
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class NestPipeline
|
public static class NestPipeline
|
||||||
{
|
{
|
||||||
|
private sealed class PreviewProgress(IProgress<NestJobProgress> inner) : IProgress<NestJobProgress>
|
||||||
|
{
|
||||||
|
public void Report(NestJobProgress value)
|
||||||
|
{
|
||||||
|
if (value != null && value.Stage == NestJobStage.EvaluatingCandidate)
|
||||||
|
inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
|
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
|
||||||
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
|
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
|
||||||
/// Engine cancellation propagates unchanged.</summary>
|
/// Engine cancellation propagates unchanged.</summary>
|
||||||
@@ -93,6 +106,32 @@ public static class NestPipeline
|
|||||||
NestPipelineRequest request,
|
NestPipelineRequest request,
|
||||||
IProgress<NestJobProgress> progress = null,
|
IProgress<NestJobProgress> progress = null,
|
||||||
CancellationToken token = default
|
CancellationToken token = default
|
||||||
|
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false);
|
||||||
|
|
||||||
|
/// <summary>Internal test-only integration trial. Normal Run never prepares cutouts;
|
||||||
|
/// this entry point must not be called by production front ends before cutting/post gates.</summary>
|
||||||
|
internal static NestPipelineResult RunCutoutPreview(
|
||||||
|
INestingEngine engine,
|
||||||
|
string engineName,
|
||||||
|
NestPipelineRequest request,
|
||||||
|
IProgress<NestJobProgress> progress = null,
|
||||||
|
CancellationToken token = default
|
||||||
|
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: true);
|
||||||
|
|
||||||
|
internal static NestPipelineResult RunCutoutPreview(
|
||||||
|
NestPipelineRequest request,
|
||||||
|
IProgress<NestJobProgress> progress = null,
|
||||||
|
CancellationToken token = default
|
||||||
|
) => RunCutoutPreview(NestingEngineRegistry.Create(request.EngineName),
|
||||||
|
request.EngineName, request, progress, token);
|
||||||
|
|
||||||
|
private static NestPipelineResult RunCore(
|
||||||
|
INestingEngine engine,
|
||||||
|
string engineName,
|
||||||
|
NestPipelineRequest request,
|
||||||
|
IProgress<NestJobProgress> progress,
|
||||||
|
CancellationToken token,
|
||||||
|
bool cutoutPreview
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(engine);
|
ArgumentNullException.ThrowIfNull(engine);
|
||||||
@@ -115,10 +154,15 @@ public static class NestPipeline
|
|||||||
|
|
||||||
var job = new NestJob(parts, request.Stock, request.Options);
|
var job = new NestJob(parts, request.Stock, request.Options);
|
||||||
NestJobValidator.Validate(job);
|
NestJobValidator.Validate(job);
|
||||||
|
var prepass = cutoutPreview ? CutoutPipelinePrepass.Prepare(job, token) : null;
|
||||||
|
var engineJob = prepass?.EngineJob ?? job;
|
||||||
|
if (prepass != null)
|
||||||
|
NestJobValidator.Validate(engineJob);
|
||||||
|
|
||||||
var clock = Stopwatch.StartNew();
|
var clock = Stopwatch.StartNew();
|
||||||
var raw =
|
var raw =
|
||||||
engine.Solve(job, progress, token)
|
engine.Solve(engineJob, prepass == null || progress == null
|
||||||
|
? progress : new PreviewProgress(progress), token)
|
||||||
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
|
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
|
||||||
var solveTime = clock.Elapsed;
|
var solveTime = clock.Elapsed;
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -129,7 +173,33 @@ public static class NestPipeline
|
|||||||
kv => kv.Value.Name ?? kv.Key,
|
kv => kv.Value.Name ?? kv.Key,
|
||||||
StringComparer.Ordinal
|
StringComparer.Ordinal
|
||||||
);
|
);
|
||||||
var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep);
|
var checkedNames = prepass == null ? names : engineJob.Parts.ToDictionary(p => p.Id,
|
||||||
|
p => names.GetValueOrDefault(p.Id, p.Id), StringComparer.Ordinal);
|
||||||
|
var violations = NestLayoutCheck.Violations(engineJob, raw, checkedNames, out var canKeep).ToList();
|
||||||
|
if (prepass != null)
|
||||||
|
{
|
||||||
|
// A compound may be physically invalid even when its envelope is legal.
|
||||||
|
// Refuse every unverified trial, including with explicit invalid-result consent.
|
||||||
|
var failure = "Cutout engine output failed reconciliation";
|
||||||
|
if (violations.Count == 0 && prepass.TryExpand(job, raw, token, out var expanded, out failure))
|
||||||
|
{
|
||||||
|
raw = expanded;
|
||||||
|
violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep));
|
||||||
|
if (raw.Status != NestJobStatus.Complete)
|
||||||
|
violations.Add("Cutout engine did not complete the transformed job; unplaced frames' reserved inserts remain unplaced in the original job");
|
||||||
|
canKeep = physicalKeep && violations.Count == 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (violations.Count == 0)
|
||||||
|
violations.Add(failure);
|
||||||
|
canKeep = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var freshness = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId);
|
||||||
|
violations.AddRange(freshness);
|
||||||
|
if (freshness.Count > 0)
|
||||||
|
canKeep = false;
|
||||||
var validationTime = clock.Elapsed;
|
var validationTime = clock.Elapsed;
|
||||||
|
|
||||||
var plates = canKeep
|
var plates = canKeep
|
||||||
@@ -141,6 +211,31 @@ public static class NestPipeline
|
|||||||
.ToList()
|
.ToList()
|
||||||
: new List<ProposedPlate>();
|
: new List<ProposedPlate>();
|
||||||
|
|
||||||
|
// Binding clones the caller's current Program; a concurrent edit during binding
|
||||||
|
// must not return parts whose bytes differ from the already validated snapshot.
|
||||||
|
if (canKeep)
|
||||||
|
{
|
||||||
|
var changes = NestPipelineDrawingFreshness.Changes(job, drawingsByPartId).ToList();
|
||||||
|
changes.AddRange(NestPipelineDrawingFreshness.BoundChanges(job, raw, plates, drawingsByPartId));
|
||||||
|
if (changes.Count > 0)
|
||||||
|
{
|
||||||
|
violations.AddRange(changes);
|
||||||
|
plates.Clear();
|
||||||
|
canKeep = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (prepass != null && canKeep)
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
for (var i = 0; i < raw.Plates.Count; i++)
|
||||||
|
{
|
||||||
|
count += raw.Plates[i].Placements.Count;
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
|
||||||
|
raw.Plates[i].StockId, i, i + 1, count));
|
||||||
|
}
|
||||||
|
}
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
return new NestPipelineResult(
|
return new NestPipelineResult(
|
||||||
engineName,
|
engineName,
|
||||||
@@ -150,7 +245,8 @@ public static class NestPipeline
|
|||||||
violations,
|
violations,
|
||||||
canKeep,
|
canKeep,
|
||||||
solveTime,
|
solveTime,
|
||||||
validationTime
|
validationTime,
|
||||||
|
drawingsByPartId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,11 @@ public static class NestPipelineCommit
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
if (!result.CanKeep || (!result.IsValid && !allowInvalid))
|
if (!result.CanKeep || (!result.IsValid && !allowInvalid))
|
||||||
throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations.");
|
throw new InvalidOperationException("The nesting result cannot be committed without a keepable layout and explicit consent to its violations.");
|
||||||
|
if (NestPipelineDrawingFreshness.Changes(result.Job, result.DrawingsByPartId).Count > 0
|
||||||
|
|| NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
|
||||||
|
result.Plates, result.DrawingsByPartId).Count > 0)
|
||||||
|
throw new InvalidOperationException("A drawing or proposed part changed after the nesting proposal was validated; run Auto Nest again.");
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
var applied = new List<Plate>();
|
var applied = new List<Plate>();
|
||||||
// Commit is synchronous on the caller's owning thread. Cancellation is checked
|
// Commit is synchronous on the caller's owning thread. Cancellation is checked
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
|
/// <summary>Refuse a proposal when a caller drawing no longer matches its checked snapshot.</summary>
|
||||||
|
internal static class NestPipelineDrawingFreshness
|
||||||
|
{
|
||||||
|
internal static IReadOnlyList<string> Changes(NestJob job,
|
||||||
|
IReadOnlyDictionary<string, Drawing> drawings)
|
||||||
|
{
|
||||||
|
var changed = new List<string>();
|
||||||
|
foreach (var requirement in job.Parts)
|
||||||
|
{
|
||||||
|
if (!drawings.TryGetValue(requirement.Id, out var drawing)
|
||||||
|
|| drawing?.Program == null)
|
||||||
|
{
|
||||||
|
changed.Add($"Drawing for '{requirement.Id}' changed after the nesting snapshot");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var current = PartGeometrySnapshot.FromProgram(drawing.Program);
|
||||||
|
if (Same(requirement.Geometry, current))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
|
||||||
|
or InvalidOperationException)
|
||||||
|
{
|
||||||
|
// A now-unsupported program is not the geometry that was checked.
|
||||||
|
}
|
||||||
|
changed.Add($"Drawing '{drawing.Name ?? requirement.Id}' changed after the nesting snapshot");
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Check the actual cloned programs, not only the drawing before/after
|
||||||
|
/// binding. A transient edit during Clone may leave the drawing unchanged.</summary>
|
||||||
|
internal static IReadOnlyList<string> BoundChanges(NestJob job, NestJobResult raw,
|
||||||
|
IReadOnlyList<ProposedPlate> plates, IReadOnlyDictionary<string, Drawing> drawings)
|
||||||
|
{
|
||||||
|
var changed = new List<string>();
|
||||||
|
var requirements = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
||||||
|
if (plates.Count != raw.Plates.Count)
|
||||||
|
return new[] { "Bound plate count changed after validation" };
|
||||||
|
for (var sheetIndex = 0; sheetIndex < plates.Count; sheetIndex++)
|
||||||
|
{
|
||||||
|
var poses = raw.Plates[sheetIndex].Placements;
|
||||||
|
var bound = plates[sheetIndex].Parts;
|
||||||
|
if (bound.Count != poses.Count)
|
||||||
|
return new[] { "Bound part count changed after validation" };
|
||||||
|
for (var index = 0; index < poses.Count; index++)
|
||||||
|
{
|
||||||
|
var pose = poses[index];
|
||||||
|
var actual = bound[index];
|
||||||
|
if (pose.PartId == null || !requirements.TryGetValue(pose.PartId, out var requirement)
|
||||||
|
|| !drawings.TryGetValue(pose.PartId, out var drawing)
|
||||||
|
|| !ReferenceEquals(actual.BaseDrawing, drawing))
|
||||||
|
return new[] { "A bound part no longer matches its requirement" };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var expected = new Part(DrawingJobMapper.CreateDrawing(requirement));
|
||||||
|
expected.Rotate(pose.Rotation);
|
||||||
|
expected.Location = new Vector(pose.X, pose.Y);
|
||||||
|
if (Same(PartGeometrySnapshot.FromProgram(expected.Program),
|
||||||
|
PartGeometrySnapshot.FromProgram(actual.Program))
|
||||||
|
&& Bits(expected.Location.X) == Bits(actual.Location.X)
|
||||||
|
&& Bits(expected.Location.Y) == Bits(actual.Location.Y)
|
||||||
|
&& Bits(expected.Rotation) == Bits(actual.Rotation))
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
|
||||||
|
or InvalidOperationException)
|
||||||
|
{
|
||||||
|
// A changed/unsupported program cannot represent the checked pose.
|
||||||
|
}
|
||||||
|
changed.Add($"Bound part for '{drawing.Name ?? requirement.Id}' changed after validation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Same(PartGeometrySnapshot a, PartGeometrySnapshot b) =>
|
||||||
|
a.Mode == b.Mode && a.Motions.Count == b.Motions.Count
|
||||||
|
&& a.Motions.Zip(b.Motions).All(pair =>
|
||||||
|
pair.First.Type == pair.Second.Type
|
||||||
|
&& Bits(pair.First.X) == Bits(pair.Second.X)
|
||||||
|
&& Bits(pair.First.Y) == Bits(pair.Second.Y)
|
||||||
|
&& Bits(pair.First.CenterX) == Bits(pair.Second.CenterX)
|
||||||
|
&& Bits(pair.First.CenterY) == Bits(pair.Second.CenterY)
|
||||||
|
&& pair.First.Rotation == pair.Second.Rotation
|
||||||
|
&& pair.First.Layer == pair.Second.Layer
|
||||||
|
&& pair.First.Suppressed == pair.Second.Suppressed);
|
||||||
|
|
||||||
|
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Mcp;
|
||||||
|
using OpenNest.Mcp.Tools;
|
||||||
|
|
||||||
|
namespace OpenNest.FrontEnd.Tests;
|
||||||
|
|
||||||
|
[Collection("FrontEndRegistry")]
|
||||||
|
public class McpMixReportingTests
|
||||||
|
{
|
||||||
|
private static Drawing Rectangle(string name, double width, double length)
|
||||||
|
{
|
||||||
|
var shape = new Shape();
|
||||||
|
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(width, 0)));
|
||||||
|
shape.Entities.Add(new Line(new Vector(width, 0), new Vector(width, length)));
|
||||||
|
shape.Entities.Add(new Line(new Vector(width, length), new Vector(0, length)));
|
||||||
|
shape.Entities.Add(new Line(new Vector(0, length), new Vector(0, 0)));
|
||||||
|
return new Drawing(name, OpenNest.Converters.ConvertGeometry.ToProgram(shape));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestSession Session()
|
||||||
|
{
|
||||||
|
var session = new NestSession();
|
||||||
|
session.Drawings.Add(Rectangle("A", 2, 2));
|
||||||
|
session.Drawings.Add(Rectangle("B", 20, 20));
|
||||||
|
session.Plates.Add(new Plate(10, 10));
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PackReportsZeroForUnfitDrawingAndCountsOnlyNewParts()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var plate = session.GetPlate(0);
|
||||||
|
plate.Parts.Add(new Part(session.Drawings[0]));
|
||||||
|
var text = new NestingTools(session).PackPlate(0, "A,B", "10,10");
|
||||||
|
Assert.Contains("A: requested=10", text);
|
||||||
|
Assert.Contains("B: requested=10, placed=0, remaining=10", text);
|
||||||
|
Assert.Contains("remaining demand", text);
|
||||||
|
Assert.DoesNotContain("ratio not met", text);
|
||||||
|
Assert.Contains($"A: requested=10, placed={plate.Parts.Count - 1}, remaining={11 - plate.Parts.Count}", text);
|
||||||
|
Assert.True(plate.Parts.Count > 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutoNestReportsSkewedAndZeroProgressByRequirement()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var engine = "MixStub-" + Guid.NewGuid();
|
||||||
|
NestingEngineRegistry.Register(engine, "test", () => new Stub());
|
||||||
|
var tool = new NestingTools(session);
|
||||||
|
var text = tool.AutoNestPlate(0, "A,B", "2,2", engine);
|
||||||
|
Assert.Contains("A: requested=2, placed=1, remaining=1", text);
|
||||||
|
Assert.Contains("B: requested=2, placed=0, remaining=2", text);
|
||||||
|
Assert.Contains("remaining demand", text);
|
||||||
|
Assert.DoesNotContain("ratio not met", text);
|
||||||
|
|
||||||
|
// Duplicate tokens must be refused before the engine can mutate a fresh target.
|
||||||
|
var fresh = Session();
|
||||||
|
Assert.Contains("duplicate", new NestingTools(fresh).AutoNestPlate(0, "A,A", "2,2", engine), StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Empty(fresh.GetPlate(0).Parts);
|
||||||
|
Assert.Single(session.GetPlate(0).Parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PackZeroProgressAndAmbiguousNamesAreExplicit()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
session.GetPlate(0).Size = new Size(1, 1);
|
||||||
|
var text = new NestingTools(session).PackPlate(0, "A,B", "2,2");
|
||||||
|
Assert.Contains("A: requested=2, placed=0, remaining=2", text);
|
||||||
|
Assert.Contains("B: requested=2, placed=0, remaining=2", text);
|
||||||
|
Assert.Contains("zero progress", text);
|
||||||
|
Assert.Empty(session.GetPlate(0).Parts);
|
||||||
|
|
||||||
|
session.Drawings.Add(Rectangle("A", 1, 1));
|
||||||
|
Assert.Contains("ambiguous drawing name", new NestingTools(session).PackPlate(0, "A,B", "2,2"));
|
||||||
|
Assert.Empty(session.GetPlate(0).Parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZeroDemandHasZeroRemainingAndNegativeDemandIsRejected()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var tools = new NestingTools(session);
|
||||||
|
var text = tools.PackPlate(0, "A", "0");
|
||||||
|
Assert.Contains("A: requested=0, placed=0, remaining=0", text);
|
||||||
|
Assert.Contains("zero progress", text);
|
||||||
|
Assert.Contains("negative", tools.PackPlate(0, "A", "-1"), StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Empty(session.GetPlate(0).Parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Stub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var placed = new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) };
|
||||||
|
return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.Completed,
|
||||||
|
[new NestJobPlateResult(0, job.Plates[0], placed)],
|
||||||
|
[], []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
using OpenNest.Mcp;
|
||||||
|
using OpenNest.Mcp.Tools;
|
||||||
|
|
||||||
|
namespace OpenNest.FrontEnd.Tests;
|
||||||
|
|
||||||
|
public class McpPlateToolsTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-mcp-plates-" + Guid.NewGuid());
|
||||||
|
|
||||||
|
public McpPlateToolsTests() => Directory.CreateDirectory(directory);
|
||||||
|
public void Dispose() => Directory.Delete(directory, true);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NegativeIndexOnLoadedNestIsNotAPlate()
|
||||||
|
{
|
||||||
|
var session = new NestSession { Nest = new Nest() };
|
||||||
|
var loaded = new Plate(10, 20);
|
||||||
|
session.Nest.Plates.Add(loaded);
|
||||||
|
var appended = new Plate(30, 40);
|
||||||
|
session.Plates.Add(appended);
|
||||||
|
|
||||||
|
Assert.Null(session.GetPlate(-1));
|
||||||
|
Assert.Equal("Error: plate -1 not found", new SetupTools(session).ClearPlate(-1));
|
||||||
|
Assert.Same(loaded, session.GetPlate(0));
|
||||||
|
Assert.Same(appended, session.GetPlate(1));
|
||||||
|
Assert.Null(session.GetPlate(2));
|
||||||
|
Assert.Single(session.Nest.Plates);
|
||||||
|
Assert.Single(session.Plates);
|
||||||
|
|
||||||
|
var sessionOnly = new NestSession();
|
||||||
|
sessionOnly.Plates.Add(appended);
|
||||||
|
Assert.Null(sessionOnly.GetPlate(-1));
|
||||||
|
Assert.Same(appended, sessionOnly.GetPlate(0));
|
||||||
|
Assert.Null(sessionOnly.GetPlate(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Delete(SetupTools tools, int index) => tools.DeletePlate(index);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteEmptyLoadedMiddlePlateRenumbersAppendedAndPersists()
|
||||||
|
{
|
||||||
|
var session = new NestSession { Nest = new Nest("fixture") };
|
||||||
|
var drawing = new Drawing("square", new Program());
|
||||||
|
session.Nest.Drawings.Add(drawing);
|
||||||
|
var occupied = new Plate(20, 30) { Quantity = 2 };
|
||||||
|
occupied.Parts.Add(new Part(drawing));
|
||||||
|
var unused = new Plate(40, 50);
|
||||||
|
var appended = new Plate(60, 70) { PartSpacing = 1.25, Quantity = 3 };
|
||||||
|
session.Nest.Plates.Add(occupied);
|
||||||
|
session.Nest.Plates.Add(unused);
|
||||||
|
session.Plates.Add(appended);
|
||||||
|
var tools = new SetupTools(session);
|
||||||
|
|
||||||
|
var result = Delete(tools, 1);
|
||||||
|
Assert.Contains("Deleted plate 1", result);
|
||||||
|
Assert.Contains("2 -> 1", result);
|
||||||
|
Assert.Single(session.Nest.Plates);
|
||||||
|
Assert.Same(appended, session.GetPlate(1));
|
||||||
|
Assert.Null(session.GetPlate(2));
|
||||||
|
Assert.Contains("60.0 x 70.0", new InspectionTools(session).GetPlateInfo(1));
|
||||||
|
Assert.Equal(2, drawing.Quantity.Nested);
|
||||||
|
|
||||||
|
var path = Path.Combine(directory, "cleaned.nest");
|
||||||
|
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
|
||||||
|
var reloaded = new NestReader(path).Read();
|
||||||
|
// NestWriter already omits empty plates; the deletion is session cleanup,
|
||||||
|
// and the saved file must still retain the occupied plate and its counts.
|
||||||
|
var saved = Assert.Single(reloaded.Plates);
|
||||||
|
Assert.Single(saved.Parts);
|
||||||
|
Assert.Equal(20, saved.Size.Width);
|
||||||
|
Assert.Equal(2, saved.Quantity);
|
||||||
|
Assert.Equal(2, reloaded.Drawings.Single().Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteRefusesOccupiedCutoffAndInvalidIndicesWithoutMutation()
|
||||||
|
{
|
||||||
|
var session = new NestSession { Nest = new Nest("fixture") };
|
||||||
|
var drawing = new Drawing("square", new Program());
|
||||||
|
session.Nest.Drawings.Add(drawing);
|
||||||
|
var occupied = new Plate(20, 30) { Quantity = 4 };
|
||||||
|
var part = new Part(drawing);
|
||||||
|
occupied.Parts.Add(part);
|
||||||
|
var cutoff = new Plate(40, 50);
|
||||||
|
var definition = new CutOff(new Vector(10, 10), CutOffAxis.Vertical);
|
||||||
|
cutoff.CutOffs.Add(definition);
|
||||||
|
session.Nest.Plates.Add(occupied);
|
||||||
|
session.Plates.Add(cutoff);
|
||||||
|
var tools = new SetupTools(session);
|
||||||
|
|
||||||
|
Assert.Contains("occupied", Delete(tools, 0));
|
||||||
|
Assert.Contains("cutoff", Delete(tools, 1), StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("not found", Delete(tools, -1));
|
||||||
|
Assert.Contains("not found", Delete(tools, 2));
|
||||||
|
Assert.Same(occupied, session.GetPlate(0));
|
||||||
|
Assert.Same(cutoff, session.GetPlate(1));
|
||||||
|
Assert.Same(part, Assert.Single(occupied.Parts));
|
||||||
|
Assert.Same(definition, Assert.Single(cutoff.CutOffs));
|
||||||
|
Assert.Equal(4, drawing.Quantity.Nested);
|
||||||
|
Assert.Single(session.Nest.Plates);
|
||||||
|
Assert.Single(session.Plates);
|
||||||
|
|
||||||
|
// The session-only collection must also delete without requiring a loaded nest.
|
||||||
|
var standalone = new NestSession();
|
||||||
|
standalone.Plates.Add(new Plate(12, 13));
|
||||||
|
Assert.Contains("Deleted plate 0", Delete(new SetupTools(standalone), 0));
|
||||||
|
Assert.Empty(standalone.Plates);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
using OpenNest.Mcp;
|
||||||
|
using OpenNest.Mcp.Tools;
|
||||||
|
|
||||||
|
namespace OpenNest.FrontEnd.Tests;
|
||||||
|
|
||||||
|
[Collection("FrontEndRegistry")]
|
||||||
|
public class McpWholeJobTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-mcp-job-" + Guid.NewGuid());
|
||||||
|
public McpWholeJobTests() => Directory.CreateDirectory(directory);
|
||||||
|
public void Dispose() => Directory.Delete(directory, true);
|
||||||
|
|
||||||
|
private static Drawing Square(string name, double size = 2)
|
||||||
|
{
|
||||||
|
var shape = new Shape();
|
||||||
|
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(size, 0)));
|
||||||
|
shape.Entities.Add(new Line(new Vector(size, 0), new Vector(size, size)));
|
||||||
|
shape.Entities.Add(new Line(new Vector(size, size), new Vector(0, size)));
|
||||||
|
shape.Entities.Add(new Line(new Vector(0, size), new Vector(0, 0)));
|
||||||
|
return new Drawing(name, OpenNest.Converters.ConvertGeometry.ToProgram(shape));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestSession Session()
|
||||||
|
{
|
||||||
|
var session = new NestSession { Nest = new Nest("job") };
|
||||||
|
session.Nest.Drawings.Add(Square("A"));
|
||||||
|
session.Nest.Drawings.Add(Square("B"));
|
||||||
|
session.Nest.Plates.Add(new Plate(10, 10) { PartSpacing = 0.25, Quantity = 1 });
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Call(NestingTools tools, int plateIndex, string names, string quantities,
|
||||||
|
string engine, bool noNew = false, CancellationToken token = default) =>
|
||||||
|
tools.AutoNestJob(plateIndex, names, quantities, engine, noNew, token);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FiniteSheetInputsReachEngineWithTemplateSettings()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var template = session.GetPlate(0);
|
||||||
|
template.Size = new Size(60, 120);
|
||||||
|
template.EdgeSpacing = new Spacing(1, 2, 3, 4);
|
||||||
|
template.Quadrant = 3;
|
||||||
|
var engine = "StockMap-" + Guid.NewGuid();
|
||||||
|
var called = false;
|
||||||
|
NestingEngineRegistry.Register(engine, "inspect finite stock", () => new InspectStub(job =>
|
||||||
|
{
|
||||||
|
called = true;
|
||||||
|
Assert.Equal(2, job.Plates.Count);
|
||||||
|
Assert.Equal(["stock-0", "stock-1"], job.Plates.Select(s => s.Id));
|
||||||
|
Assert.Equal([new Size(60, 120), new Size(48, 96)], job.Plates.Select(s => s.Size));
|
||||||
|
Assert.Equal([1, 2], job.Plates.Select(s => s.Quantity));
|
||||||
|
Assert.All(job.Plates, stock =>
|
||||||
|
{
|
||||||
|
Assert.Equal(0.25, stock.PartSpacing);
|
||||||
|
Assert.Equal(template.EdgeSpacing, stock.EdgeSpacing);
|
||||||
|
Assert.Equal(3, stock.Quadrant);
|
||||||
|
});
|
||||||
|
Assert.Equal(2, job.Options.MaxPlates); // capped by demand, not plate.Quantity
|
||||||
|
return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.StockExhausted, [], [], []);
|
||||||
|
}));
|
||||||
|
var rows = new[] { new SheetStockInput { Width = 60, Length = 120, Quantity = 1 },
|
||||||
|
new SheetStockInput { Width = 48, Length = 96, Quantity = 2 } };
|
||||||
|
var tool = new NestingTools(session);
|
||||||
|
Assert.Contains("incomplete", tool.AutoNestJob(0, "A,B", "1,1", engine, sheets: rows));
|
||||||
|
Assert.True(called);
|
||||||
|
called = false;
|
||||||
|
rows[1].Width = double.NaN;
|
||||||
|
Assert.Contains("sheets[1]", tool.AutoNestJob(0, "A,B", "1,1", engine, sheets: rows));
|
||||||
|
Assert.False(called);
|
||||||
|
Assert.Single(session.AllPlates());
|
||||||
|
Assert.Empty(template.Parts);
|
||||||
|
Assert.Equal(1, template.Quantity); // no inventory drawn from the template quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InspectStub(Func<NestJob, NestJobResult> solve) : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default) => solve(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FiniteMixedSheetsCommitByStockIdAndRoundTrip()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var template = session.GetPlate(0);
|
||||||
|
template.Size = new Size(6, 6);
|
||||||
|
template.Quantity = 9; // repeat count is not inventory
|
||||||
|
var small = session.Nest.Drawings.Single(d => d.Name == "B");
|
||||||
|
var large = Square("large", 4); // four-by-four cannot fit a three-by-three sheet
|
||||||
|
session.Nest.Drawings.Add(large);
|
||||||
|
var rows = new[] { new SheetStockInput { Width = 6, Length = 6, Quantity = 1 },
|
||||||
|
new SheetStockInput { Width = 3, Length = 3, Quantity = 2 } };
|
||||||
|
var engine = "MixedStock-" + Guid.NewGuid();
|
||||||
|
NestingEngineRegistry.Register(engine, "two different sheets", () => new InspectStub(job =>
|
||||||
|
{
|
||||||
|
Assert.Equal(2, job.Options.MaxPlates);
|
||||||
|
Assert.Equal([1, 2], job.Plates.Select(s => s.Quantity));
|
||||||
|
// Small stock first: result ordinal must not resize/reuse the large template.
|
||||||
|
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
[new NestJobPlateResult(0, job.Plates[1], [new NestJobPlacement(job.Parts[1].Id, 0, 0.5, 0.5, 0)]),
|
||||||
|
new NestJobPlateResult(1, job.Plates[0], [new NestJobPlacement(job.Parts[0].Id, 1, 0.5, 0.5, 0)])], [], []);
|
||||||
|
}));
|
||||||
|
var text = new NestingTools(session).AutoNestJob(0, "large,B", "1,1", engine, sheets: rows);
|
||||||
|
Assert.Contains("Whole job complete", text);
|
||||||
|
Assert.Contains("stock-0 (6x6): used=1, remaining=0, reused=1, created=0", text);
|
||||||
|
Assert.Contains("stock-1 (3x3): used=1, remaining=1, reused=0, created=1", text);
|
||||||
|
Assert.Contains("plate 0 -> stock-0", text);
|
||||||
|
Assert.Contains("plate 1 -> stock-1", text);
|
||||||
|
Assert.Same(template, session.GetPlate(0));
|
||||||
|
Assert.Equal(new Size(6, 6), template.Size);
|
||||||
|
Assert.Same(large, Assert.Single(template.Parts).BaseDrawing);
|
||||||
|
Assert.Same(small, Assert.Single(session.GetPlate(1).Parts).BaseDrawing);
|
||||||
|
Assert.Equal(1, template.Quantity);
|
||||||
|
var path = Path.Combine(directory, "mixed.nest");
|
||||||
|
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
|
||||||
|
var reloaded = new NestReader(path).Read();
|
||||||
|
Assert.Equal([new Size(6, 6), new Size(3, 3)], reloaded.Plates.Select(p => p.Size));
|
||||||
|
Assert.All(reloaded.Plates, p => Assert.Equal(1, p.Quantity));
|
||||||
|
Assert.Equal(1, reloaded.Drawings.Single(d => d.Name == "large").Quantity.Nested);
|
||||||
|
Assert.Equal(1, reloaded.Drawings.Single(d => d.Name == "B").Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FiniteStockOverdrawIsRejectedBeforeAnySessionMutation()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var plate = session.GetPlate(0);
|
||||||
|
plate.Size = new Size(3, 3);
|
||||||
|
var engine = "Overdraw-" + Guid.NewGuid();
|
||||||
|
NestingEngineRegistry.Register(engine, "overdraw stock", () => new InspectStub(job =>
|
||||||
|
new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
[new NestJobPlateResult(0, job.Plates[0], [new NestJobPlacement(job.Parts[0].Id, 0, 0.5, 0.5, 0)]),
|
||||||
|
new NestJobPlateResult(1, job.Plates[0], [new NestJobPlacement(job.Parts[1].Id, 1, 0.5, 0.5, 0)])], [], [])));
|
||||||
|
var text = new NestingTools(session).AutoNestJob(0, "A,B", "1,1", engine,
|
||||||
|
sheets: [new SheetStockInput { Width = 3, Length = 3, Quantity = 1 },
|
||||||
|
new SheetStockInput { Width = 6, Length = 6, Quantity = 2 }]);
|
||||||
|
Assert.Contains("stock-0", text);
|
||||||
|
Assert.Contains("invalid", text, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Single(session.AllPlates());
|
||||||
|
Assert.Empty(plate.Parts);
|
||||||
|
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FiniteStockRejectsInvalidInventoryBeforeSolving()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var plate = session.GetPlate(0);
|
||||||
|
var engine = "NeverSolve-" + Guid.NewGuid();
|
||||||
|
NestingEngineRegistry.Register(engine, "should not be called", () => new InspectStub(_ =>
|
||||||
|
throw new Xunit.Sdk.XunitException("invalid input reached solver")));
|
||||||
|
var tools = new NestingTools(session);
|
||||||
|
var valid = new SheetStockInput { Width = 10, Length = 10, Quantity = 1 };
|
||||||
|
Assert.Contains("at least one", tools.AutoNestJob(0, "A", "1", engine, sheets: []));
|
||||||
|
Assert.Contains("sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
|
||||||
|
sheets: [new SheetStockInput { Width = 10, Length = 10, Quantity = 0 }]));
|
||||||
|
Assert.Contains("sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
|
||||||
|
sheets: [new SheetStockInput { Width = double.PositiveInfinity, Length = 10, Quantity = 1 }]));
|
||||||
|
Assert.Contains("sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
|
||||||
|
sheets: [new SheetStockInput { Width = 0, Length = 10, Quantity = 1 }]));
|
||||||
|
Assert.Contains("sheets[1] duplicates sheets[0]", tools.AutoNestJob(0, "A", "1", engine,
|
||||||
|
sheets: [valid, new SheetStockInput { Width = 10, Length = 10, Quantity = 2 }]));
|
||||||
|
Assert.Contains("sheet inventory exceeds", tools.AutoNestJob(0, "A", "1", engine,
|
||||||
|
sheets: [new SheetStockInput { Width = 10, Length = 10, Quantity = int.MaxValue },
|
||||||
|
new SheetStockInput { Width = 11, Length = 11, Quantity = 1 }]));
|
||||||
|
Assert.Contains("cannot be combined", tools.AutoNestJob(0, "A", "1", engine,
|
||||||
|
no_new_plates: true, sheets: [valid]));
|
||||||
|
Assert.Single(session.AllPlates());
|
||||||
|
Assert.Empty(plate.Parts);
|
||||||
|
Assert.Equal(1, plate.Quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FiniteInventoryUsesOnlyOneOfTwoMatchingEmptySheets()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var original = session.GetPlate(0);
|
||||||
|
session.Plates.Add(new Plate(10, 10) { PartSpacing = 0.25, Quantity = 5 });
|
||||||
|
var text = new NestingTools(session).AutoNestJob(0, "A", "1", "Rectangles",
|
||||||
|
sheets: [new SheetStockInput { Width = 10, Length = 10, Quantity = 1 }]);
|
||||||
|
Assert.Contains("Whole job complete", text);
|
||||||
|
Assert.Contains("stock-0 (10x10): used=1, remaining=0", text);
|
||||||
|
Assert.Single(original.Parts);
|
||||||
|
Assert.Empty(session.GetPlate(1).Parts);
|
||||||
|
Assert.Equal(5, session.GetPlate(1).Quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LaterSelectedTemplateIsReusedBeforeOtherCompatibleEmptySheet()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var earlier = session.GetPlate(0);
|
||||||
|
var selected = new Plate(10, 10) { PartSpacing = 0.25, Quantity = 3 };
|
||||||
|
session.Plates.Add(selected);
|
||||||
|
var text = new NestingTools(session).AutoNestJob(1, "A", "1", "Rectangles");
|
||||||
|
Assert.Contains("Whole job complete", text);
|
||||||
|
Assert.Empty(earlier.Parts);
|
||||||
|
Assert.Single(selected.Parts);
|
||||||
|
Assert.Equal(1, selected.Quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreatesSheetsForCompleteMixedJobThenSavesAndReloads()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var original = session.GetPlate(0);
|
||||||
|
var engine = "JobTwoSheets-" + Guid.NewGuid();
|
||||||
|
NestingEngineRegistry.Register(engine, "two safe sheets", () => new TwoSheetStub());
|
||||||
|
var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine);
|
||||||
|
Assert.Contains("complete", text, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal(2, session.AllPlates().Count);
|
||||||
|
Assert.Same(original, session.GetPlate(0));
|
||||||
|
Assert.Single(session.Plates);
|
||||||
|
Assert.All(session.AllPlates(), plate =>
|
||||||
|
{
|
||||||
|
Assert.Equal(2, plate.Parts.Count);
|
||||||
|
Assert.Equal(0.25, plate.PartSpacing);
|
||||||
|
Assert.Equal(1, plate.Quantity);
|
||||||
|
});
|
||||||
|
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
|
||||||
|
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
|
||||||
|
var path = Path.Combine(directory, "complete.nest");
|
||||||
|
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
|
||||||
|
var loaded = new NestReader(path).Read();
|
||||||
|
Assert.Equal(2, loaded.Plates.Count);
|
||||||
|
Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "A").Quantity.Nested);
|
||||||
|
Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "B").Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoNewPlateFlagRefusesIncompleteProposalWithoutMutation()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var original = session.GetPlate(0);
|
||||||
|
var engine = "JobLimited-" + Guid.NewGuid();
|
||||||
|
NestingEngineRegistry.Register(engine, "one sheet when capped", () => new TwoSheetStub());
|
||||||
|
var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine, noNew: true);
|
||||||
|
Assert.Contains("incomplete", text, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("Nothing committed", text);
|
||||||
|
Assert.Single(session.AllPlates());
|
||||||
|
Assert.Same(original, session.GetPlate(0));
|
||||||
|
Assert.Empty(original.Parts);
|
||||||
|
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExistingQuantityAndPrecreatedSheetsCompleteWithRealEngine()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var occupied = session.GetPlate(0);
|
||||||
|
occupied.Quantity = 2;
|
||||||
|
occupied.Parts.Add(new Part(session.Nest.Drawings.Single(d => d.Name == "A")));
|
||||||
|
var first = new Plate(3, 3) { PartSpacing = 0.25, Quantity = 1 };
|
||||||
|
var second = new Plate(3, 3) { PartSpacing = 0.25, Quantity = 1 };
|
||||||
|
session.Plates.Add(first);
|
||||||
|
session.Plates.Add(second);
|
||||||
|
var text = Call(new NestingTools(session), 1, "A,B", "3,1", "Rectangles", noNew: true);
|
||||||
|
Assert.Contains("Whole job complete", text);
|
||||||
|
Assert.Contains("0 new sheet(s)", text);
|
||||||
|
Assert.Same(first, session.GetPlate(1));
|
||||||
|
Assert.Same(second, session.GetPlate(2));
|
||||||
|
Assert.Equal(3, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
|
||||||
|
Assert.Equal(1, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
|
||||||
|
Assert.Equal(3, session.AllPlates().Count);
|
||||||
|
var path = Path.Combine(directory, "existing.nest");
|
||||||
|
Assert.Contains("Saved nest", new InputTools(session).SaveNest(path));
|
||||||
|
var loaded = new NestReader(path).Read();
|
||||||
|
Assert.Equal(3, loaded.Plates.Count);
|
||||||
|
Assert.Equal(3, loaded.Drawings.Single(d => d.Name == "A").Quantity.Nested);
|
||||||
|
Assert.Equal(1, loaded.Drawings.Single(d => d.Name == "B").Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RealEngineAddsSheetsWhenOnlyTemplateExists()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
session.GetPlate(0).Size = new Size(3, 3);
|
||||||
|
var text = Call(new NestingTools(session), 0, "A,B", "2,2", "Rectangles");
|
||||||
|
Assert.Contains("Whole job complete", text);
|
||||||
|
Assert.Equal(4, session.AllPlates().Count);
|
||||||
|
Assert.All(session.AllPlates(), p => Assert.Single(p.Parts));
|
||||||
|
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
|
||||||
|
Assert.Equal(2, session.Nest.Drawings.Single(d => d.Name == "B").Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidPluginLayoutCannotCreateOrOccupySheets()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var original = session.GetPlate(0);
|
||||||
|
var engine = "JobOverlap-" + Guid.NewGuid();
|
||||||
|
NestingEngineRegistry.Register(engine, "invalid layout", () => new TwoSheetStub(overlap: true));
|
||||||
|
var text = Call(new NestingTools(session), 0, "A,B", "2,2", engine);
|
||||||
|
Assert.Contains("invalid", text, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("Nothing committed", text);
|
||||||
|
Assert.Single(session.AllPlates());
|
||||||
|
Assert.Same(original, session.GetPlate(0));
|
||||||
|
Assert.Empty(original.Parts);
|
||||||
|
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RestrictedDrawingRejectsPluginRotationWithoutMutationAfterReload()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var drawing = session.Nest.Drawings.Single(d => d.Name == "A");
|
||||||
|
drawing.Constraints = new NestConstraints
|
||||||
|
{
|
||||||
|
StepAngle = OpenNest.Math.Angle.TwoPI,
|
||||||
|
StartAngle = 0,
|
||||||
|
EndAngle = 0,
|
||||||
|
};
|
||||||
|
session.GetPlate(0).Parts.Add(new Part(session.Nest.Drawings.Single(d => d.Name == "B")));
|
||||||
|
var path = Path.Combine(directory, "rotation.nest");
|
||||||
|
Assert.True(new NestWriter(session.Nest).Write(path));
|
||||||
|
session = new NestSession { Nest = new NestReader(path).Read() };
|
||||||
|
Assert.Equal(OpenNest.Math.Angle.TwoPI, session.Nest.Drawings.Single(d => d.Name == "A").Constraints.StepAngle);
|
||||||
|
session.Plates.Add(new Plate(10, 10) { PartSpacing = 0.25, Quantity = 1 });
|
||||||
|
var engine = "JobForbiddenRotation-" + Guid.NewGuid();
|
||||||
|
NestingEngineRegistry.Register(engine, "rotated proposal", () => new RotatedStub());
|
||||||
|
var plateTool = new NestingTools(session).AutoNestPlate(1, "A", "1", engine);
|
||||||
|
Assert.Contains("Violation", plateTool);
|
||||||
|
Assert.Contains("rotation", plateTool, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Empty(session.GetPlate(1).Parts);
|
||||||
|
var text = Call(new NestingTools(session), 1, "A", "1", engine);
|
||||||
|
Assert.True(text.Contains("rotation", StringComparison.OrdinalIgnoreCase), text);
|
||||||
|
Assert.Contains("Nothing committed", text);
|
||||||
|
Assert.Equal(2, session.AllPlates().Count);
|
||||||
|
Assert.Empty(session.GetPlate(1).Parts);
|
||||||
|
Assert.Single(session.GetPlate(0).Parts);
|
||||||
|
Assert.Equal(0, session.Nest.Drawings.Single(d => d.Name == "A").Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EquivalentDeserializedCuttingSettingsReuseExistingSheets()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var first = session.GetPlate(0);
|
||||||
|
first.Size = new Size(3, 3);
|
||||||
|
first.CuttingParameters = new CuttingParameters { MachineName = "Same" };
|
||||||
|
var second = new Plate(3, 3)
|
||||||
|
{
|
||||||
|
Quantity = 1,
|
||||||
|
PartSpacing = 0.25,
|
||||||
|
CuttingParameters = new CuttingParameters { MachineName = "Same" }
|
||||||
|
};
|
||||||
|
session.Plates.Add(second);
|
||||||
|
Assert.NotSame(first.CuttingParameters, second.CuttingParameters);
|
||||||
|
var text = Call(new NestingTools(session), 0, "A", "2", "Rectangles", noNew: true);
|
||||||
|
Assert.Contains("Whole job complete", text);
|
||||||
|
Assert.Equal(2, session.AllPlates().Count);
|
||||||
|
Assert.Single(first.Parts);
|
||||||
|
Assert.Single(second.Parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentCuttingSettingsAreNotReusedAsStock()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var first = session.GetPlate(0);
|
||||||
|
first.Size = new Size(3, 3);
|
||||||
|
first.CuttingParameters = new CuttingParameters { MachineName = "First" };
|
||||||
|
var second = new Plate(3, 3)
|
||||||
|
{
|
||||||
|
Quantity = 1,
|
||||||
|
PartSpacing = 0.25,
|
||||||
|
CuttingParameters = new CuttingParameters { MachineName = "Other" }
|
||||||
|
};
|
||||||
|
session.Plates.Add(second);
|
||||||
|
var text = Call(new NestingTools(session), 0, "A", "2", "Rectangles", noNew: true);
|
||||||
|
Assert.Contains("incomplete", text, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("Nothing committed", text);
|
||||||
|
Assert.Empty(first.Parts);
|
||||||
|
Assert.Empty(second.Parts);
|
||||||
|
Assert.Equal(2, session.AllPlates().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SolverNotSupportedErrorNamesActualCause()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
var engine = "JobUnsupported-" + Guid.NewGuid();
|
||||||
|
NestingEngineRegistry.Register(engine, "unsupported solver", () => new UnsupportedStub());
|
||||||
|
var text = Call(new NestingTools(session), 0, "A", "1", engine);
|
||||||
|
Assert.Contains("unsupported fixture", text);
|
||||||
|
Assert.DoesNotContain("Use autonest_plate", text);
|
||||||
|
Assert.Contains("Nothing committed", text);
|
||||||
|
Assert.Empty(session.GetPlate(0).Parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationAfterPluginIgnoresTokenDoesNotCommit()
|
||||||
|
{
|
||||||
|
var session = Session();
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var engine = "JobCancel-" + Guid.NewGuid();
|
||||||
|
NestingEngineRegistry.Register(engine, "cancels after solving", () => new CancelStub(cts));
|
||||||
|
Assert.ThrowsAny<OperationCanceledException>(() =>
|
||||||
|
Call(new NestingTools(session), 0, "A,B", "2,2", engine, token: cts.Token));
|
||||||
|
Assert.Single(session.AllPlates());
|
||||||
|
Assert.Empty(session.GetPlate(0).Parts);
|
||||||
|
Assert.All(session.Nest.Drawings, d => Assert.Equal(0, d.Quantity.Nested));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CancelStub(CancellationTokenSource source) : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var result = new TwoSheetStub().Solve(job, progress, token);
|
||||||
|
source.Cancel();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RotatedStub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var stock = Assert.Single(job.Plates);
|
||||||
|
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
[new NestJobPlateResult(0, stock,
|
||||||
|
[new NestJobPlacement(job.Parts[0].Id, 0, 4, 4, System.Math.PI / 2)])], [], []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class UnsupportedStub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default) => throw new NotSupportedException("unsupported fixture");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TwoSheetStub(bool overlap = false) : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var stock = Assert.Single(job.Plates);
|
||||||
|
var sheets = new List<NestJobPlateResult>();
|
||||||
|
for (var i = 0; i < System.Math.Min(2, stock.Quantity ?? 2); i++)
|
||||||
|
sheets.Add(new NestJobPlateResult(i, stock,
|
||||||
|
[new NestJobPlacement(job.Parts[0].Id, i, 1, 1, 0),
|
||||||
|
new NestJobPlacement(job.Parts[1].Id, i, overlap ? 1 : 5, 1, 0)]));
|
||||||
|
return new NestJobResult(sheets.Count == 2 ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||||
|
sheets.Count == 2 ? NestJobStopReason.Completed : NestJobStopReason.StockExhausted,
|
||||||
|
sheets, [], []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ namespace OpenNest.Mcp
|
|||||||
|
|
||||||
public Plate GetPlate(int index)
|
public Plate GetPlate(int index)
|
||||||
{
|
{
|
||||||
|
if (index < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
if (Nest != null && index < Nest.Plates.Count)
|
if (Nest != null && index < Nest.Plates.Count)
|
||||||
return Nest.Plates[index];
|
return Nest.Plates[index];
|
||||||
|
|
||||||
@@ -27,6 +30,38 @@ namespace OpenNest.Mcp
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Remove only a genuinely unused sheet; loaded sheets precede appended sheets.</summary>
|
||||||
|
public bool TryDeleteEmptyPlate(int index, out string reason)
|
||||||
|
{
|
||||||
|
var plate = GetPlate(index);
|
||||||
|
if (plate == null)
|
||||||
|
{
|
||||||
|
reason = $"plate {index} not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plate.Parts.Count != 0)
|
||||||
|
{
|
||||||
|
reason = $"plate {index} is occupied";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plate.CutOffs.Count != 0)
|
||||||
|
{
|
||||||
|
reason = $"plate {index} has cutoff definitions";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadedCount = Nest?.Plates.Count ?? 0;
|
||||||
|
if (index < loadedCount)
|
||||||
|
Nest.Plates.RemoveAt(index);
|
||||||
|
else
|
||||||
|
Plates.RemoveAt(index - loadedCount);
|
||||||
|
|
||||||
|
reason = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public Drawing GetDrawing(string name)
|
public Drawing GetDrawing(string name)
|
||||||
{
|
{
|
||||||
if (Nest != null)
|
if (Nest != null)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace OpenNest.Mcp;
|
||||||
|
|
||||||
|
/// <summary>Request-local physical inventory for one whole-job solve.</summary>
|
||||||
|
public sealed class SheetStockInput
|
||||||
|
{
|
||||||
|
public double Width { get; set; }
|
||||||
|
public double Length { get; set; }
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ using OpenNest.Engine.Fill;
|
|||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.Engine.Jobs.Placement;
|
using OpenNest.Engine.Jobs.Placement;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Mcp.Tools
|
namespace OpenNest.Mcp.Tools
|
||||||
{
|
{
|
||||||
@@ -257,10 +258,7 @@ namespace OpenNest.Mcp.Tools
|
|||||||
sb.AppendLine($" Total parts: {countAfter}");
|
sb.AppendLine($" Total parts: {countAfter}");
|
||||||
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
||||||
|
|
||||||
// Breakdown by drawing
|
AppendMixSummary(sb, items, parts);
|
||||||
var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name);
|
|
||||||
foreach (var group in groups)
|
|
||||||
sb.AppendLine($" {group.Key}: {group.Count()}");
|
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
@@ -333,11 +331,217 @@ namespace OpenNest.Mcp.Tools
|
|||||||
sb.AppendLine($" Parts placed: {totalPlaced}");
|
sb.AppendLine($" Parts placed: {totalPlaced}");
|
||||||
sb.AppendLine($" Total parts: {plate.Parts.Count}");
|
sb.AppendLine($" Total parts: {plate.Parts.Count}");
|
||||||
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
||||||
foreach (var group in plate.Parts.GroupBy(p => p.BaseDrawing.Name))
|
AppendMixSummary(sb, parsed.items, proposed == null ? Enumerable.Empty<Part>() : proposed.Parts);
|
||||||
sb.AppendLine($" {group.Key}: {group.Count()}");
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[McpServerTool(Name = "autonest_job")]
|
||||||
|
[Description("Complete requested drawing quantities across physical sheets. The selected empty plate supplies common settings; optional sheets offer finite sizes and quantities. An incomplete or invalid proposal changes nothing.")]
|
||||||
|
public string AutoNestJob(
|
||||||
|
[Description("Index of an empty plate to use as the stock template")] int plateIndex,
|
||||||
|
[Description("Comma-separated drawing names")] string drawingNames,
|
||||||
|
[Description("Comma-separated total requested quantities for the entire session")] string quantities,
|
||||||
|
[Description(JobEngines)] string engine = null,
|
||||||
|
[Description("Use only already-created empty plates with matching dimensions and spacing; do not add sheets")] bool no_new_plates = false,
|
||||||
|
CancellationToken cancellationToken = default,
|
||||||
|
[Description("Optional finite physical sheet stock: width, length and available quantity per size. Incompatible with no_new_plates.")] SheetStockInput[] sheets = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var template = _session.GetPlate(plateIndex);
|
||||||
|
if (template == null)
|
||||||
|
return $"Error: plate {plateIndex} not found";
|
||||||
|
if (template.Parts.Count != 0 || template.CutOffs.Count != 0)
|
||||||
|
return "Error: stock template must be empty and have no cutoff definitions. Nothing committed.";
|
||||||
|
if (sheets != null && no_new_plates)
|
||||||
|
return "Error: sheets cannot be combined with no_new_plates. Nothing committed.";
|
||||||
|
var offeredStock = BuildSheetStock(template, sheets);
|
||||||
|
if (offeredStock.error != null)
|
||||||
|
return offeredStock.error;
|
||||||
|
if (string.IsNullOrWhiteSpace(drawingNames) || string.IsNullOrWhiteSpace(quantities))
|
||||||
|
return "Error: drawingNames and quantities are required";
|
||||||
|
var parsed = ParseItems(drawingNames, quantities);
|
||||||
|
if (parsed.error != null)
|
||||||
|
return parsed.error;
|
||||||
|
if (parsed.items.Any(item => item.Quantity <= 0))
|
||||||
|
return "Error: whole-job quantities must be positive";
|
||||||
|
|
||||||
|
var existing = _session.AllPlates();
|
||||||
|
if (existing.Any(p => p.Parts.Count > 0 && p.Quantity <= 0))
|
||||||
|
return "Error: an occupied plate has a nonpositive quantity. Nothing committed.";
|
||||||
|
var remaining = new List<NestItem>(parsed.items.Count);
|
||||||
|
var counts = new Dictionary<Drawing, long>(ReferenceEqualityComparer.Instance);
|
||||||
|
foreach (var item in parsed.items)
|
||||||
|
{
|
||||||
|
var nested = existing.Sum(p => (long)p.Parts.Count(part => ReferenceEquals(part.BaseDrawing, item.Drawing)) * p.Quantity);
|
||||||
|
if (nested > item.Quantity)
|
||||||
|
return $"Error: drawing '{item.Drawing.Name}' already exceeds requested quantity ({nested} > {item.Quantity}). Nothing committed.";
|
||||||
|
counts[item.Drawing] = nested;
|
||||||
|
if (nested < item.Quantity)
|
||||||
|
remaining.Add(new NestItem
|
||||||
|
{
|
||||||
|
Drawing = item.Drawing,
|
||||||
|
Quantity = (int)(item.Quantity - nested),
|
||||||
|
Priority = item.Priority,
|
||||||
|
StepAngle = item.StepAngle,
|
||||||
|
RotationStart = item.RotationStart,
|
||||||
|
RotationEnd = item.RotationEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (remaining.Count == 0)
|
||||||
|
return "Job already complete: all requested quantities are present. Nothing committed.";
|
||||||
|
|
||||||
|
var available = new List<Plate> { template };
|
||||||
|
available.AddRange(existing.Where(p => !ReferenceEquals(p, template)
|
||||||
|
&& p.Parts.Count == 0 && p.CutOffs.Count == 0 && SameStock(p, template)));
|
||||||
|
var demand = remaining.Sum(item => (long)item.Quantity);
|
||||||
|
if (demand > int.MaxValue)
|
||||||
|
return "Error: total remaining demand exceeds the supported sheet limit. Nothing committed.";
|
||||||
|
var limit = no_new_plates ? System.Math.Min(available.Count, (int)demand)
|
||||||
|
: sheets != null ? (int)System.Math.Min(offeredStock.total, demand) : (int)demand;
|
||||||
|
var engineName = string.IsNullOrWhiteSpace(engine) ? _session.DefaultEngineName : engine.Trim();
|
||||||
|
if (NestingEngineRegistry.ResolveName(engineName) == null)
|
||||||
|
return $"Error: unknown whole-job engine '{engineName}'. Nothing committed. Available: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}";
|
||||||
|
NestPipelineResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = NestPipeline.Run(new NestPipelineRequest(engineName, remaining,
|
||||||
|
sheets == null ? NestStockBuilder.FromTemplate(template, null, no_new_plates ? available.Count : null) : offeredStock.stock,
|
||||||
|
new NestJobOptions(maxPlates: limit)), token: cancellationToken);
|
||||||
|
}
|
||||||
|
catch (NotSupportedException ex)
|
||||||
|
{
|
||||||
|
return $"Error: whole-job request unsupported: {ex.Message}. Nothing committed.";
|
||||||
|
}
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!result.CanKeep || !result.IsValid)
|
||||||
|
return "Error: invalid whole-job proposal. Nothing committed. " + string.Join("; ", result.Violations);
|
||||||
|
|
||||||
|
var placed = new Dictionary<Drawing, long>(ReferenceEqualityComparer.Instance);
|
||||||
|
foreach (var sheet in result.Plates)
|
||||||
|
foreach (var part in sheet.Parts)
|
||||||
|
placed[part.BaseDrawing] = placed.GetValueOrDefault(part.BaseDrawing) + 1;
|
||||||
|
if (remaining.Any(item => placed.GetValueOrDefault(item.Drawing) != item.Quantity))
|
||||||
|
{
|
||||||
|
var summary = string.Join(", ", remaining.Select(item =>
|
||||||
|
$"{item.Drawing.Name}: {placed.GetValueOrDefault(item.Drawing)}/{item.Quantity} newly placed"));
|
||||||
|
return $"Error: incomplete whole-job proposal ({result.StopReason}); {summary}. Nothing committed. A solver's no-placement result does not prove geometric impossibility.";
|
||||||
|
}
|
||||||
|
if (result.Plates.Count > limit)
|
||||||
|
return "Error: proposal exceeds the allowed sheet count. Nothing committed.";
|
||||||
|
|
||||||
|
// Build the entire target map before attaching a single part. Indexes in a mixed
|
||||||
|
// stock proposal are solver ordinals, not indexes into the session's empty plates.
|
||||||
|
var usedTargets = new HashSet<Plate>(ReferenceEqualityComparer.Instance);
|
||||||
|
var targets = new List<(Plate plate, bool created, string stockId)>();
|
||||||
|
foreach (var proposed in result.Plates)
|
||||||
|
{
|
||||||
|
var target = new[] { template }.Concat(existing.Where(p => !ReferenceEquals(p, template)))
|
||||||
|
.FirstOrDefault(p => !usedTargets.Contains(p)
|
||||||
|
&& p.Parts.Count == 0 && p.CutOffs.Count == 0
|
||||||
|
&& SameStock(p, template, proposed.Stock.Size));
|
||||||
|
var created = target == null;
|
||||||
|
target ??= new Plate(proposed.Stock.Size)
|
||||||
|
{
|
||||||
|
GrainAngle = template.GrainAngle,
|
||||||
|
CuttingParameters = template.CuttingParameters,
|
||||||
|
};
|
||||||
|
usedTargets.Add(target);
|
||||||
|
targets.Add((target, created, proposed.Stock.Id));
|
||||||
|
}
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
for (var i = 0; i < result.Plates.Count; i++)
|
||||||
|
{
|
||||||
|
var proposed = result.Plates[i];
|
||||||
|
var (target, created, _) = targets[i];
|
||||||
|
target.Size = proposed.Stock.Size;
|
||||||
|
target.PartSpacing = proposed.Stock.PartSpacing;
|
||||||
|
target.EdgeSpacing = proposed.Stock.EdgeSpacing;
|
||||||
|
target.Quadrant = proposed.Stock.Quadrant;
|
||||||
|
target.Quantity = 1;
|
||||||
|
if (created)
|
||||||
|
_session.Plates.Add(target);
|
||||||
|
target.Parts.AddRange(proposed.Parts);
|
||||||
|
}
|
||||||
|
var lines = new StringBuilder();
|
||||||
|
lines.AppendLine($"Whole job complete: {result.Plates.Count} sheet(s) placed, {targets.Count(t => t.created)} new sheet(s) created.");
|
||||||
|
if (sheets != null)
|
||||||
|
{
|
||||||
|
foreach (var stock in offeredStock.stock)
|
||||||
|
{
|
||||||
|
var used = targets.Count(t => t.stockId == stock.Id);
|
||||||
|
lines.AppendLine($" {stock.Id} ({stock.Size.Width}x{stock.Size.Length}): used={used}, remaining={stock.Quantity - used}, reused={targets.Count(t => !t.created && t.stockId == stock.Id)}, created={targets.Count(t => t.created && t.stockId == stock.Id)}");
|
||||||
|
}
|
||||||
|
foreach (var (target, _, stockId) in targets)
|
||||||
|
lines.AppendLine($" plate {_session.AllPlates().FindIndex(p => ReferenceEquals(p, target))} -> {stockId}");
|
||||||
|
}
|
||||||
|
foreach (var item in parsed.items)
|
||||||
|
lines.AppendLine($" {item.Drawing.Name}: requested={item.Quantity}, already={counts[item.Drawing]}, newly placed={placed.GetValueOrDefault(item.Drawing)}, remaining=0");
|
||||||
|
return lines.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (List<NestPlateStock> stock, long total, string error) BuildSheetStock(Plate template, SheetStockInput[] sheets)
|
||||||
|
{
|
||||||
|
if (sheets == null)
|
||||||
|
return (null, 0, null);
|
||||||
|
if (sheets.Length == 0)
|
||||||
|
return (null, 0, "Error: sheets must contain at least one stock size. Nothing committed.");
|
||||||
|
var stock = new List<NestPlateStock>(sheets.Length);
|
||||||
|
long total = 0;
|
||||||
|
for (var i = 0; i < sheets.Length; i++)
|
||||||
|
{
|
||||||
|
var row = sheets[i];
|
||||||
|
if (row == null || !double.IsFinite(row.Width) || !double.IsFinite(row.Length)
|
||||||
|
|| row.Width <= 0 || row.Length <= 0 || row.Quantity <= 0
|
||||||
|
|| row.Length <= template.EdgeSpacing.Left + template.EdgeSpacing.Right
|
||||||
|
|| row.Width <= template.EdgeSpacing.Top + template.EdgeSpacing.Bottom)
|
||||||
|
return (null, 0, $"Error: sheets[{i}] requires finite positive usable dimensions and a positive physical quantity. Nothing committed.");
|
||||||
|
var duplicate = stock.FindIndex(s => s.Size.Width == row.Width && s.Size.Length == row.Length);
|
||||||
|
if (duplicate >= 0)
|
||||||
|
return (null, 0, $"Error: sheets[{i}] duplicates sheets[{duplicate}] size. Nothing committed.");
|
||||||
|
total += row.Quantity;
|
||||||
|
if (total > int.MaxValue)
|
||||||
|
return (null, 0, "Error: total sheet inventory exceeds the supported sheet limit. Nothing committed.");
|
||||||
|
stock.Add(new NestPlateStock($"stock-{i}", new Size(row.Width, row.Length), row.Quantity,
|
||||||
|
template.PartSpacing, template.EdgeSpacing, template.Quadrant));
|
||||||
|
}
|
||||||
|
return (stock, total, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameStock(Plate a, Plate b) => SameStock(a, b, b.Size);
|
||||||
|
|
||||||
|
private static bool SameStock(Plate a, Plate b, Size size) =>
|
||||||
|
a.Size.Width == size.Width && a.Size.Length == size.Length
|
||||||
|
&& a.PartSpacing == b.PartSpacing && a.EdgeSpacing.Equals(b.EdgeSpacing)
|
||||||
|
&& a.Quadrant == b.Quadrant && a.GrainAngle == b.GrainAngle
|
||||||
|
&& (a.CuttingParameters == null && b.CuttingParameters == null
|
||||||
|
|| a.CuttingParameters != null && b.CuttingParameters != null
|
||||||
|
&& string.Equals(CuttingParametersSerializer.Serialize(a.CuttingParameters),
|
||||||
|
CuttingParametersSerializer.Serialize(b.CuttingParameters), StringComparison.Ordinal));
|
||||||
|
|
||||||
|
private static void AppendMixSummary(StringBuilder sb, IReadOnlyList<NestItem> items, IEnumerable<Part> newlyPlaced)
|
||||||
|
{
|
||||||
|
var counts = new Dictionary<Drawing, int>(ReferenceEqualityComparer.Instance);
|
||||||
|
foreach (var part in newlyPlaced)
|
||||||
|
counts[part.BaseDrawing] = counts.TryGetValue(part.BaseDrawing, out var n) ? n + 1 : 1;
|
||||||
|
|
||||||
|
var placed = new int[items.Count];
|
||||||
|
sb.AppendLine(" Requested mix (new placements only):");
|
||||||
|
for (var i = 0; i < items.Count; i++)
|
||||||
|
{
|
||||||
|
var item = items[i];
|
||||||
|
placed[i] = counts.GetValueOrDefault(item.Drawing);
|
||||||
|
var remaining = item.Quantity - placed[i];
|
||||||
|
sb.AppendLine($" [{i}] {item.Drawing.Name}: requested={item.Quantity}, placed={placed[i]}, remaining={remaining}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placed.All(count => count == 0))
|
||||||
|
sb.AppendLine(" Mix: zero progress for all requested drawings.");
|
||||||
|
else if (items.Where((item, i) => item.Quantity > placed[i]).Any())
|
||||||
|
sb.AppendLine(" Warning: remaining demand in this call; place the outstanding quantities on another sheet before treating the job as complete.");
|
||||||
|
else
|
||||||
|
sb.AppendLine(" Mix: requested quantities placed in this call.");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the requested fill strategy; an omitted name means Fill. Returns null when the
|
/// Resolves the requested fill strategy; an omitted name means Fill. Returns null when the
|
||||||
/// name is not a single-plate placement strategy.
|
/// name is not a single-plate placement strategy.
|
||||||
@@ -376,6 +580,8 @@ namespace OpenNest.Mcp.Tools
|
|||||||
{
|
{
|
||||||
if (!int.TryParse(qtyStrings[i], out qtys[i]))
|
if (!int.TryParse(qtyStrings[i], out qtys[i]))
|
||||||
return (null, $"Error: '{qtyStrings[i]}' is not a valid quantity");
|
return (null, $"Error: '{qtyStrings[i]}' is not a valid quantity");
|
||||||
|
if (qtys[i] < 0)
|
||||||
|
return (null, $"Error: negative quantity '{qtys[i]}' is not supported");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (names.Length != qtys.Length)
|
if (names.Length != qtys.Length)
|
||||||
@@ -385,14 +591,28 @@ namespace OpenNest.Mcp.Tools
|
|||||||
);
|
);
|
||||||
|
|
||||||
var items = new List<NestItem>();
|
var items = new List<NestItem>();
|
||||||
|
var seen = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
for (var i = 0; i < names.Length; i++)
|
for (var i = 0; i < names.Length; i++)
|
||||||
{
|
{
|
||||||
var drawing = _session.GetDrawing(names[i]);
|
var matches = _session.AllDrawings().Where(d => d.Name == names[i]).ToArray();
|
||||||
if (drawing == null)
|
if (matches.Length == 0)
|
||||||
return (null, $"Error: drawing '{names[i]}' not found");
|
return (null, $"Error: drawing '{names[i]}' not found");
|
||||||
|
if (matches.Length > 1)
|
||||||
|
return (null, $"Error: ambiguous drawing name '{names[i]}' matches multiple drawings");
|
||||||
|
var drawing = matches[0];
|
||||||
|
if (!seen.Add(drawing))
|
||||||
|
return (null, $"Error: duplicate drawing '{names[i]}' in request");
|
||||||
|
|
||||||
items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] });
|
items.Add(new NestItem
|
||||||
|
{
|
||||||
|
Drawing = drawing,
|
||||||
|
Quantity = qtys[i],
|
||||||
|
Priority = drawing.Priority,
|
||||||
|
StepAngle = drawing.Constraints?.StepAngle ?? 0,
|
||||||
|
RotationStart = drawing.Constraints?.StartAngle ?? 0,
|
||||||
|
RotationEnd = drawing.Constraints?.EndAngle ?? 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (items, null);
|
return (items, null);
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ namespace OpenNest.Mcp.Tools
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[McpServerTool(Name = "delete_plate")]
|
||||||
|
[Description("Delete an empty plate without parts or cutoff definitions. Later plate indices shift down by one.")]
|
||||||
|
public string DeletePlate([Description("Index of the empty plate to delete")] int plateIndex)
|
||||||
|
{
|
||||||
|
var oldCount = _session.AllPlates().Count;
|
||||||
|
if (!_session.TryDeleteEmptyPlate(plateIndex, out var reason))
|
||||||
|
return $"Error: {reason}; no plates changed";
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"Deleted plate {plateIndex}. Plates: {oldCount} -> {oldCount - 1}.");
|
||||||
|
for (var oldIndex = plateIndex + 1; oldIndex < oldCount; oldIndex++)
|
||||||
|
sb.AppendLine($" {oldIndex} -> {oldIndex - 1}");
|
||||||
|
sb.AppendLine("Use get_plate_info with the new indices before further operations.");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "clear_plate")]
|
[McpServerTool(Name = "clear_plate")]
|
||||||
[Description("Remove all parts from a plate. Returns how many parts were removed.")]
|
[Description("Remove all parts from a plate. Returns how many parts were removed.")]
|
||||||
public string ClearPlate([Description("Index of the plate to clear")] int plateIndex)
|
public string ClearPlate([Description("Index of the plate to clear")] int plateIndex)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public sealed class NestDatabaseConcurrencyTests
|
|||||||
public async Task Concurrency_EveryOperationWaitsForTheSameMonitor(string operation)
|
public async Task Concurrency_EveryOperationWaitsForTheSameMonitor(string operation)
|
||||||
{
|
{
|
||||||
using var factory = new ServerFactory();
|
using var factory = new ServerFactory();
|
||||||
using var database = new NestDatabase(factory.DatabasePath);
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
var record = new NestRecord { Name = "Monitor ownership fixture" };
|
var record = new NestRecord { Name = "Monitor ownership fixture" };
|
||||||
database.Insert(id, record, new byte[] { 1, 2, 3 });
|
database.Insert(id, record, new byte[] { 1, 2, 3 });
|
||||||
@@ -75,7 +75,7 @@ public sealed class NestDatabaseConcurrencyTests
|
|||||||
public void Concurrency_AllSqlStepsIncludingReadbacks_OwnTheMonitor()
|
public void Concurrency_AllSqlStepsIncludingReadbacks_OwnTheMonitor()
|
||||||
{
|
{
|
||||||
using var factory = new ServerFactory();
|
using var factory = new ServerFactory();
|
||||||
using var database = new NestDatabase(factory.DatabasePath);
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||||
var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!
|
var sync = typeof(NestDatabase).GetField("_sync", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
.GetValue(database)!;
|
.GetValue(database)!;
|
||||||
var connection = (SqliteConnection)typeof(NestDatabase)
|
var connection = (SqliteConnection)typeof(NestDatabase)
|
||||||
@@ -125,7 +125,7 @@ public sealed class NestDatabaseConcurrencyTests
|
|||||||
public async Task Concurrency_DatabaseReadersAndWriters_PreserveExactRecords()
|
public async Task Concurrency_DatabaseReadersAndWriters_PreserveExactRecords()
|
||||||
{
|
{
|
||||||
using var factory = new ServerFactory();
|
using var factory = new ServerFactory();
|
||||||
using var database = new NestDatabase(factory.DatabasePath);
|
using var database = new NestDatabase(factory.DatabasePath, pooling: false);
|
||||||
using var start = new Barrier(4);
|
using var start = new Barrier(4);
|
||||||
var workers = Enumerable.Range(0, 4).Select(lane => Task.Factory.StartNew(() =>
|
var workers = Enumerable.Range(0, 4).Select(lane => Task.Factory.StartNew(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
|
||||||
@@ -16,6 +15,7 @@ namespace OpenNest.Server.Tests;
|
|||||||
public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
||||||
{
|
{
|
||||||
private bool _cleanedUp;
|
private bool _cleanedUp;
|
||||||
|
private NestDatabase? _database;
|
||||||
|
|
||||||
public ServerFactory(string? databasePath = null)
|
public ServerFactory(string? databasePath = null)
|
||||||
{
|
{
|
||||||
@@ -34,7 +34,8 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
|||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
{
|
{
|
||||||
services.RemoveAll<NestDatabase>();
|
services.RemoveAll<NestDatabase>();
|
||||||
services.AddSingleton(_ => new NestDatabase(DatabasePath));
|
// Register an externally owned instance; this factory closes it before deleting its file.
|
||||||
|
services.AddSingleton(_database = new NestDatabase(DatabasePath, pooling: false));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +46,7 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
_cleanedUp = true;
|
_cleanedUp = true;
|
||||||
// Pooled connections would keep the file open on Windows after the host disposed it.
|
_database?.Dispose();
|
||||||
SqliteConnection.ClearAllPools();
|
|
||||||
if (Directory.Exists(DirectoryPath))
|
if (Directory.Exists(DirectoryPath))
|
||||||
Directory.Delete(DirectoryPath, recursive: true);
|
Directory.Delete(DirectoryPath, recursive: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,18 @@ public sealed class NestDatabase : IDisposable
|
|||||||
private readonly object _sync = new();
|
private readonly object _sync = new();
|
||||||
private readonly SqliteConnection _connection;
|
private readonly SqliteConnection _connection;
|
||||||
|
|
||||||
public NestDatabase(string databasePath)
|
public NestDatabase(string databasePath, bool pooling = true)
|
||||||
{
|
{
|
||||||
var directory = Path.GetDirectoryName(Path.GetFullPath(databasePath));
|
var directory = Path.GetDirectoryName(Path.GetFullPath(databasePath));
|
||||||
if (!string.IsNullOrEmpty(directory))
|
if (!string.IsNullOrEmpty(directory))
|
||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
_connection = new SqliteConnection($"Data Source={databasePath}");
|
// Test databases are short-lived files: disabling pooling releases Windows file handles on dispose.
|
||||||
|
_connection = new SqliteConnection(new SqliteConnectionStringBuilder
|
||||||
|
{
|
||||||
|
DataSource = databasePath,
|
||||||
|
Pooling = pooling,
|
||||||
|
}.ToString());
|
||||||
_connection.Open();
|
_connection.Open();
|
||||||
Execute("""
|
Execute("""
|
||||||
PRAGMA journal_mode=WAL;
|
PRAGMA journal_mode=WAL;
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.Engine.CuttingPlanning;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
public class BestEffortCuttingPlanTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TouchingContours_OfferAPreviewWithoutChangingTheDrawing()
|
||||||
|
{
|
||||||
|
var part = TouchingContours();
|
||||||
|
var plate = Plate(part);
|
||||||
|
var original = OwnedProgramCopy.Copy(part.BaseDrawing.Program);
|
||||||
|
var strict = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
||||||
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
||||||
|
Assert.Equal(CuttingPlanStatus.UnsupportedGeometry, strict.Status);
|
||||||
|
|
||||||
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
|
||||||
|
Assert.NotNull(proposal.BuildPreview(0));
|
||||||
|
Assert.False(proposal.CanApply);
|
||||||
|
Assert.False(part.HasManualLeadIns);
|
||||||
|
Assert.True(ProgramContent.Equal(original, part.BaseDrawing.Program));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnverifiedSummary_ShowsEveryWarningBeforeAcceptance()
|
||||||
|
{
|
||||||
|
var parts = Enumerable.Range(0, 6).Select(index =>
|
||||||
|
{
|
||||||
|
var part = TouchingContours();
|
||||||
|
part.Offset(index * 12, 0);
|
||||||
|
return part;
|
||||||
|
}).ToArray();
|
||||||
|
var proposal = CuttingPlanBatch.Capture([Plate(parts)], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance);
|
||||||
|
var findings = Assert.Single(proposal.Plates).Result.Findings.ToArray();
|
||||||
|
Assert.True(findings.Length > 5);
|
||||||
|
Assert.Contains(findings, finding => finding.SourceOrdinal == 5);
|
||||||
|
var summary = string.Join("\n", proposal.Describe("in"));
|
||||||
|
Assert.Contains("Part 6", summary);
|
||||||
|
Assert.DoesNotContain("... and", summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AcceptWarnings_InstallsOwnedProgramsAndLeavesStrictApplyClosed()
|
||||||
|
{
|
||||||
|
var part = TouchingContours();
|
||||||
|
var plate = Plate(part);
|
||||||
|
var source = OwnedProgramCopy.Copy(part.BaseDrawing.Program);
|
||||||
|
var pose = (part.Location, part.Rotation);
|
||||||
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
var result = Assert.Single(proposal.Plates).Result;
|
||||||
|
Assert.Equal(CuttingPlanStatus.BestEffort, result.Status);
|
||||||
|
Assert.False(result.IndependentlyReplayed);
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance);
|
||||||
|
Assert.Contains(result.Findings, f => f.Message.Contains("Material boundaries"));
|
||||||
|
Assert.Contains("unverified", string.Join("\n", proposal.Describe("in")));
|
||||||
|
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
|
||||||
|
Assert.Equal(CuttingCommitStatus.InvalidInput, CuttingPlanService.Apply([result]).Status);
|
||||||
|
var generated = Assert.Single(result.ProposedOrder);
|
||||||
|
Assert.True(generated.IsRegenerated);
|
||||||
|
Assert.Equal(3, generated.ContourChoices.Count);
|
||||||
|
Assert.Equal(2, generated.ContourChoices[^1].ContourOrdinal);
|
||||||
|
var originalCuts = ExecutionMotionReader.ReadSupported(source, part.Location, null).Motions
|
||||||
|
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Sum(m => m.Length);
|
||||||
|
var emittedCuts = ExecutionMotionReader.ReadSupported(generated.CopyProgram(), part.Location, null).Motions
|
||||||
|
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Sum(m => m.Length);
|
||||||
|
Assert.Equal(originalCuts, emittedCuts, 8);
|
||||||
|
var copy = generated.CopyProgram();
|
||||||
|
copy.Codes.Clear();
|
||||||
|
|
||||||
|
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: true).Status);
|
||||||
|
|
||||||
|
Assert.True(part.HasManualLeadIns);
|
||||||
|
Assert.True(ProgramContent.Equal(generated.CopyProgram(), part.Program));
|
||||||
|
Assert.True(ProgramContent.Equal(source, part.BaseDrawing.Program));
|
||||||
|
Assert.Equal(pose, (part.Location, part.Rotation));
|
||||||
|
Assert.Equal(CuttingCommitStatus.Stale, proposal.Apply(acceptWarnings: true).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("null")]
|
||||||
|
[InlineData("unknown")]
|
||||||
|
[InlineData("cycle")]
|
||||||
|
[InlineData("nonfinite")]
|
||||||
|
[InlineData("open")]
|
||||||
|
[InlineData("suppressed")]
|
||||||
|
public void UnreadableOrUnrepresentableInput_IsNeverWaived(string failure)
|
||||||
|
{
|
||||||
|
var part = TouchingContours();
|
||||||
|
var plate = Plate(part);
|
||||||
|
var program = part.BaseDrawing.Program;
|
||||||
|
switch (failure)
|
||||||
|
{
|
||||||
|
case "null": program.Codes = null; break;
|
||||||
|
case "unknown": program.Codes.Add(new CustomMove()); break;
|
||||||
|
case "cycle": program.Codes.Add(new SubProgramCall { Program = program }); break;
|
||||||
|
case "nonfinite": part.Location = new Vector(double.NaN, 1); break;
|
||||||
|
case "open": program.Codes.RemoveAt(program.Codes.Count - 1); break;
|
||||||
|
case "suppressed": program.Codes.OfType<Motion>().First().Suppressed = true; break;
|
||||||
|
}
|
||||||
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.False(proposal.CanApplyWithWarnings);
|
||||||
|
Assert.Null(proposal.BuildPreview(0));
|
||||||
|
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(acceptWarnings: true).Status);
|
||||||
|
Assert.False(part.HasManualLeadIns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MixedBatch_StaleOrCancelledChangesNothing()
|
||||||
|
{
|
||||||
|
var imperfect = TouchingContours();
|
||||||
|
var clean = new Part(new Drawing("clean", ExplicitContourTests.Square(false)), new Vector(1, 1));
|
||||||
|
var plates = new[] { Plate(clean), Plate(imperfect) };
|
||||||
|
var originals = new[] { clean.Program, imperfect.Program };
|
||||||
|
var batch = CuttingPlanBatch.Capture(plates, ExplicitContourTests.Parameters(), false);
|
||||||
|
var proposal = batch.Plan();
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance);
|
||||||
|
using var cancelled = new CancellationTokenSource();
|
||||||
|
cancelled.Cancel();
|
||||||
|
Assert.Equal(CuttingCommitStatus.Cancelled, proposal.Apply(true, cancelled.Token).Status);
|
||||||
|
Assert.False(batch.Plan(token: cancelled.Token).CanApplyWithWarnings);
|
||||||
|
imperfect.Offset(1, 0);
|
||||||
|
Assert.Null(proposal.BuildPreview(1));
|
||||||
|
Assert.Equal(CuttingCommitStatus.Stale, proposal.Apply(acceptWarnings: true).Status);
|
||||||
|
Assert.Equal(originals, plates.SelectMany(p => p.Parts).Select(p => p.Program));
|
||||||
|
Assert.All(plates, p => Assert.Null(p.CuttingParameters));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LockedPart_KeepsExactProgramWhileOtherPartsRegenerate()
|
||||||
|
{
|
||||||
|
var part = TouchingContours();
|
||||||
|
part.LeadInsLocked = true;
|
||||||
|
var program = part.Program;
|
||||||
|
var other = new Part(new Drawing("clean", ExplicitContourTests.Square(false)), new Vector(20, 1));
|
||||||
|
var proposal = CuttingPlanBatch.Capture([Plate(part, other)], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance);
|
||||||
|
Assert.False(proposal.Plates[0].Result.ProposedOrder[0].IsRegenerated);
|
||||||
|
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(true).Status);
|
||||||
|
Assert.Same(program, part.Program);
|
||||||
|
Assert.True(part.LeadInsLocked);
|
||||||
|
Assert.True(other.HasManualLeadIns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KnownPartOverlap_StillBlocksTheWholeBatch()
|
||||||
|
{
|
||||||
|
var first = new Part(new Drawing("first", ExplicitContourTests.Square(false)), new Vector(1, 1));
|
||||||
|
var second = new Part(new Drawing("second", ExplicitContourTests.Square(false)), new Vector(6, 6));
|
||||||
|
var proposal = CuttingPlanBatch.Capture([Plate(TouchingContours()), Plate(first, second)],
|
||||||
|
ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.False(proposal.CanApplyWithWarnings);
|
||||||
|
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(true).Status);
|
||||||
|
Assert.False(first.HasManualLeadIns);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void ProvenCutoffDependencies_AreNotWaived(bool keepOrder)
|
||||||
|
{
|
||||||
|
var part = TouchingContours();
|
||||||
|
var cutProgram = new Program();
|
||||||
|
cutProgram.MoveTo(20, 0); cutProgram.LineTo(20, 20);
|
||||||
|
var cutoff = new Part(new Drawing("cutoff", cutProgram) { IsCutOff = true });
|
||||||
|
var plate = Plate(part, cutoff); // An orphaned cutoff must precede all ordinary parts.
|
||||||
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), keepOrder).Plan();
|
||||||
|
if (keepOrder)
|
||||||
|
{
|
||||||
|
Assert.False(proposal.CanApplyWithWarnings);
|
||||||
|
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply(true).Status);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Assert.True(proposal.CanApplyWithWarnings);
|
||||||
|
Assert.Same(cutoff, proposal.Plates[0].Result.ProposedOrder[0].SourcePart);
|
||||||
|
Assert.False(proposal.Plates[0].Result.ProposedOrder[0].IsRegenerated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelfIntersectingClosedContour_StillGetsEveryCutAndALead()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0); program.LineTo(8, 8); program.LineTo(0, 8);
|
||||||
|
program.LineTo(8, 0); program.LineTo(0, 0);
|
||||||
|
var part = new Part(new Drawing("crossed contour", program), new Vector(1, 1));
|
||||||
|
var proposal = CuttingPlanBatch.Capture([Plate(part)], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance);
|
||||||
|
var generated = Assert.Single(proposal.Plates[0].Result.ProposedOrder);
|
||||||
|
Assert.True(generated.IsRegenerated);
|
||||||
|
Assert.Single(generated.ContourChoices);
|
||||||
|
var moves = ExecutionMotionReader.ReadSupported(generated.CopyProgram(), part.Location, null).Motions;
|
||||||
|
Assert.Contains(moves, m => m.Layer == LayerType.Leadin);
|
||||||
|
var original = ExecutionMotionReader.ReadSupported(program, part.Location, null).Motions;
|
||||||
|
Assert.Equal(original.Where(m => !m.Rapid).Sum(m => m.Length),
|
||||||
|
moves.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Sum(m => m.Length), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProvenNestedPart_PrecedesItsHostEvenWithAnImperfectNeighbour()
|
||||||
|
{
|
||||||
|
var hostProgram = ExplicitContourTests.Square(false);
|
||||||
|
hostProgram.MoveTo(2, 2); hostProgram.LineTo(8, 2); hostProgram.LineTo(8, 8);
|
||||||
|
hostProgram.LineTo(2, 8); hostProgram.LineTo(2, 2);
|
||||||
|
var host = new Part(new Drawing("host", hostProgram));
|
||||||
|
var innerProgram = new Program();
|
||||||
|
innerProgram.MoveTo(0, 0); innerProgram.LineTo(0, 1); innerProgram.LineTo(1, 1);
|
||||||
|
innerProgram.LineTo(1, 0); innerProgram.LineTo(0, 0);
|
||||||
|
var inner = new Part(new Drawing("insert", innerProgram), new Vector(4, 4));
|
||||||
|
var imperfect = TouchingContours();
|
||||||
|
imperfect.Offset(20, 0);
|
||||||
|
var proposal = CuttingPlanBatch.Capture([Plate(host, inner, imperfect)],
|
||||||
|
ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance, string.Join("\n", proposal.Describe("in")));
|
||||||
|
var order = proposal.Plates[0].Result.ProposedOrder.Select(p => p.SourcePart).ToList();
|
||||||
|
Assert.True(order.IndexOf(inner) < order.IndexOf(host));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BestEffortInstallFailure_RollsBackEveryPlate()
|
||||||
|
{
|
||||||
|
var parts = new[] { TouchingContours(), TouchingContours() };
|
||||||
|
var plates = parts.Select(p => Plate(p)).ToArray();
|
||||||
|
var originals = parts.Select(p => p.Program).ToArray();
|
||||||
|
var proposal = CuttingPlanBatch.Capture(plates, ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.True(proposal.RequiresWarningAcceptance);
|
||||||
|
var commit = CuttingPlanService.Apply(proposal.Plates.Select(p => p.Result), default,
|
||||||
|
(plate, _) => { if (ReferenceEquals(plate, plates[1])) throw new InvalidOperationException("test install fault"); },
|
||||||
|
acceptUnverified: true);
|
||||||
|
Assert.Equal(CuttingCommitStatus.Failed, commit.Status);
|
||||||
|
Assert.Equal(originals, parts.Select(p => p.Program));
|
||||||
|
Assert.All(parts, p => Assert.False(p.HasManualLeadIns));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableTheory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(45)]
|
||||||
|
public void RealDxf_PlansAndAppliesWithoutChangingTheDrawing(double degrees)
|
||||||
|
{
|
||||||
|
var path = TestConfig.GetExistingPath("BestEffortDxfPath");
|
||||||
|
Skip.If(path == null, "BestEffortDxfPath not configured in test-config.json or file not found");
|
||||||
|
var drawing = OpenNest.IO.CadImporter.ImportDrawing(path);
|
||||||
|
var part = new Part(drawing, new Vector(1, 1));
|
||||||
|
part.Rotate(degrees * System.Math.PI / 180);
|
||||||
|
var plate = Plate(part);
|
||||||
|
var source = OwnedProgramCopy.Copy(drawing.Program);
|
||||||
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
||||||
|
Assert.True(proposal.CanApplyWithWarnings, string.Join("\n", proposal.Describe("in")));
|
||||||
|
Assert.NotNull(proposal.BuildPreview(0));
|
||||||
|
Assert.True(Assert.Single(proposal.Plates[0].Result.ProposedOrder).IsRegenerated);
|
||||||
|
Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(true).Status);
|
||||||
|
Assert.True(ProgramContent.Equal(source, drawing.Program));
|
||||||
|
Assert.True(part.HasManualLeadIns);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CustomMove : LinearMove { }
|
||||||
|
|
||||||
|
internal static Part TouchingContours()
|
||||||
|
{
|
||||||
|
var program = ExplicitContourTests.Square(false);
|
||||||
|
// Two ordinary holes share an edge: closed executable contours, ambiguous material.
|
||||||
|
program.MoveTo(2, 2); program.LineTo(4, 2); program.LineTo(4, 4);
|
||||||
|
program.LineTo(2, 4); program.LineTo(2, 2);
|
||||||
|
program.MoveTo(4, 2); program.LineTo(6, 2); program.LineTo(6, 4);
|
||||||
|
program.LineTo(4, 4); program.LineTo(4, 2);
|
||||||
|
return new Part(new Drawing("touching contours", program), new Vector(1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static Plate Plate(params Part[] parts)
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(100, 100));
|
||||||
|
foreach (var part in parts)
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
return plate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -144,6 +144,63 @@ public class CuttingPlanBatchTests
|
|||||||
Assert.Null(proposal.BuildPreview(0));
|
Assert.Null(proposal.BuildPreview(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlanReorder_ShortPassReady_RetainsReplayedApplicableProposal()
|
||||||
|
{
|
||||||
|
var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1));
|
||||||
|
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||||
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
||||||
|
var full = CuttingPlanService.Plan(snapshot);
|
||||||
|
var result = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||||
|
Assert.True(result.IndependentlyReplayed);
|
||||||
|
Assert.True(result.Expansions < full.Expansions,
|
||||||
|
$"Short pass {result.Expansions}, full {full.Expansions}");
|
||||||
|
Assert.InRange(result.Expansions, 1, CuttingPlanBatch.FirstPassBudget - 1);
|
||||||
|
Assert.Equal(2, result.ProposedOrder.Count);
|
||||||
|
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
||||||
|
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlanReorder_ShortPassExhausted_UsesFullCapAndOriginalBudget()
|
||||||
|
{
|
||||||
|
var plate = Plate(new Nest(), Grid(4));
|
||||||
|
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||||
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
||||||
|
var full = CuttingPlanService.Plan(snapshot);
|
||||||
|
var recovered = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None, firstPassBudget: 1);
|
||||||
|
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, full.Status);
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, recovered.Status);
|
||||||
|
Assert.True(recovered.IndependentlyReplayed);
|
||||||
|
Assert.Equal(full.Expansions, recovered.Expansions);
|
||||||
|
Assert.Equal(full.RapidDistance, recovered.RapidDistance);
|
||||||
|
Assert.Equal(full.ProposedOrder.Select(p => p.SourceOrdinal), recovered.ProposedOrder.Select(p => p.SourceOrdinal));
|
||||||
|
for (var i = 0; i < full.ProposedOrder.Count; i++)
|
||||||
|
Assert.True(ProgramContent.Equal(full.ProposedOrder[i].CopyProgram(), recovered.ProposedOrder[i].CopyProgram()));
|
||||||
|
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlanReorder_CancelledDuringShortPass_DoesNotRetryOrMutate()
|
||||||
|
{
|
||||||
|
var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1));
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var observations = 0;
|
||||||
|
var request = new CuttingPlanRequest(plate.Parts, confirmedParameters: ExplicitContourTests.Parameters())
|
||||||
|
{ ExpansionObserver = _ => { observations++; cancellation.Cancel(); } };
|
||||||
|
var snapshot = CuttingPlanService.Capture(request);
|
||||||
|
|
||||||
|
var result = CuttingPlanBatch.PlanReorder(snapshot, cancellation.Token);
|
||||||
|
|
||||||
|
Assert.Equal(CuttingPlanStatus.Cancelled, result.Status);
|
||||||
|
Assert.Equal(1, observations);
|
||||||
|
Assert.Empty(result.ProposedOrder);
|
||||||
|
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo()
|
public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
|
using OpenNest.Engine.CuttingPlanning;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingPlanning;
|
||||||
|
|
||||||
|
public class LazyCuttingSearchTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void RankedRouteDoesNotSpendBudgetEmittingUnusedSiblings(bool preserveOrder)
|
||||||
|
{
|
||||||
|
var parts = Parts();
|
||||||
|
var programs = parts.Select(p => p.Program).ToArray();
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts, new Vector(-2, -2),
|
||||||
|
expansionBudget: 36, confirmedParameters: parameters, preservePartOrder: preserveOrder));
|
||||||
|
var result = CuttingPlanService.Plan(snapshot);
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
||||||
|
Assert.True(result.IndependentlyReplayed);
|
||||||
|
Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
|
||||||
|
Assert.InRange(result.Expansions, 1, 36);
|
||||||
|
var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default);
|
||||||
|
Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
|
||||||
|
Assert.True(replay.IndependentlyReplayed);
|
||||||
|
Assert.Equal(programs, parts.Select(p => p.Program));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void LazyTraversalKeepsBudgetAndMidSearchCancellationFailClosed(bool preserveOrder)
|
||||||
|
{
|
||||||
|
var parts = Parts();
|
||||||
|
var programs = parts.Select(p => p.Program).ToArray();
|
||||||
|
var parameters = ExplicitContourTests.Parameters();
|
||||||
|
var bounded = CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(-2, -2),
|
||||||
|
expansionBudget: 1, confirmedParameters: parameters, preservePartOrder: preserveOrder));
|
||||||
|
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status);
|
||||||
|
Assert.Equal(1, bounded.Expansions);
|
||||||
|
Assert.Empty(bounded.ProposedOrder);
|
||||||
|
Assert.False(bounded.IndependentlyReplayed);
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var cancelled = CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(-2, -2),
|
||||||
|
confirmedParameters: parameters, preservePartOrder: preserveOrder)
|
||||||
|
{ ExpansionObserver = n => { if (n == 10) cancellation.Cancel(); } }, cancellation.Token);
|
||||||
|
Assert.Equal(CuttingPlanStatus.Cancelled, cancelled.Status);
|
||||||
|
Assert.Equal(10, cancelled.Expansions);
|
||||||
|
Assert.Empty(cancelled.ProposedOrder);
|
||||||
|
Assert.False(cancelled.IndependentlyReplayed);
|
||||||
|
Assert.Equal(programs, parts.Select(p => p.Program));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part[] Parts() =>
|
||||||
|
[
|
||||||
|
new(new Drawing("first", LeadPathValidationTests.Rectangle(0, 0, 10, 10)), Vector.Zero),
|
||||||
|
new(new Drawing("second", LeadPathValidationTests.Rectangle(0, 0, 10, 10)), new Vector(20, 0)),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -119,6 +119,50 @@ public class NativeContactRegressionTests
|
|||||||
Assert.Throws<NotSupportedException>(() => circle.Contacts(line, out _));
|
Assert.Throws<NotSupportedException>(() => circle.Contacts(line, out _));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReusedNativeCurves_KeepContactAndContainmentVerdictsAcrossCallsAndThreads()
|
||||||
|
{
|
||||||
|
var lineStart = new Vector(-2, 0);
|
||||||
|
var lineEnd = new Vector(2, 0);
|
||||||
|
var arcCenter = new Vector(0, 0.003);
|
||||||
|
var arcStart = new Vector(0.003, 0.003);
|
||||||
|
var circleCenter = new Vector(0, 0);
|
||||||
|
var circleStart = new Vector(0.003, 0);
|
||||||
|
var line = PostVerificationGeometry.Curve.Create(lineStart, lineEnd, null, false);
|
||||||
|
var arc = PostVerificationGeometry.Curve.Create(arcStart, new Vector(-0.003, 0.003), arcCenter, false);
|
||||||
|
var circle = PostVerificationGeometry.Curve.Create(circleStart, circleStart, circleCenter, false);
|
||||||
|
var pairs = new[] { (line, arc), (arc, line), (line, circle), (circle, line), (arc, circle), (circle, arc) };
|
||||||
|
var probes = new[] { Vector.Zero, new Vector(0.003, 0), new Vector(0, 0.006) };
|
||||||
|
|
||||||
|
string Observe(PostVerificationGeometry.Curve a, PostVerificationGeometry.Curve b)
|
||||||
|
{
|
||||||
|
var inside = string.Join(",", probes.Select(p => $"{a.Contains(p)}:{b.Contains(p)}"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var contacts = a.Contacts(b, out var overlap);
|
||||||
|
return $"{inside}|{overlap}|" + string.Join(";", contacts.Select(p =>
|
||||||
|
$"{BitConverter.DoubleToInt64Bits(p.X)}:{BitConverter.DoubleToInt64Bits(p.Y)}"));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||||
|
{
|
||||||
|
return $"{inside}|{ex.GetType().Name}:{ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var expected = pairs.Select(p => Observe(
|
||||||
|
PostVerificationGeometry.Curve.Create(p.Item1.Start, p.Item1.End,
|
||||||
|
ReferenceEquals(p.Item1, arc) ? arcCenter : ReferenceEquals(p.Item1, circle) ? circleCenter : null, false),
|
||||||
|
PostVerificationGeometry.Curve.Create(p.Item2.Start, p.Item2.End,
|
||||||
|
ReferenceEquals(p.Item2, arc) ? arcCenter : ReferenceEquals(p.Item2, circle) ? circleCenter : null, false))).ToArray();
|
||||||
|
for (var repeat = 0; repeat < 3; repeat++)
|
||||||
|
Assert.Equal(expected, pairs.Select(p => Observe(p.Item1, p.Item2)).ToArray());
|
||||||
|
Parallel.For(0, 8, _ => Assert.Equal(expected, pairs.Select(p => Observe(p.Item1, p.Item2)).ToArray()));
|
||||||
|
// A separate pair has not initialized either native wrapper before the readers race.
|
||||||
|
var coldLine = PostVerificationGeometry.Curve.Create(lineStart, lineEnd, null, false);
|
||||||
|
var coldCircle = PostVerificationGeometry.Curve.Create(circleStart, circleStart, circleCenter, false);
|
||||||
|
Parallel.For(0, 8, _ => Assert.Equal(expected[2], Observe(coldLine, coldCircle)));
|
||||||
|
}
|
||||||
|
|
||||||
private static Program RoundedRectangle(double width, double height, double radius, double angle, Vector at)
|
private static Program RoundedRectangle(double width, double height, double radius, double angle, Vector at)
|
||||||
{
|
{
|
||||||
var cos = System.Math.Cos(angle);
|
var cos = System.Math.Cos(angle);
|
||||||
|
|||||||
@@ -34,6 +34,48 @@ public class ReorderSearchTests
|
|||||||
Assert.Equal(parts, plate.Parts); // Planning alone never reorders the live plate.
|
Assert.Equal(parts, plate.Parts); // Planning alone never reorders the live plate.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void DenseGrid144_PerformanceProbe()
|
||||||
|
{
|
||||||
|
Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_CUTTING_PERF") == "1",
|
||||||
|
"Set OPENNEST_RUN_CUTTING_PERF=1 for the 144-part cutting-planner probe.");
|
||||||
|
var nest = new Nest();
|
||||||
|
var plate = nest.CreatePlate();
|
||||||
|
plate.Size = new Size(100, 100);
|
||||||
|
foreach (var part in Grid(144, false))
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
var source = plate.Parts.ToArray();
|
||||||
|
var timer = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
|
||||||
|
confirmedParameters: ExplicitContourTests.Parameters(), expansionBudget: 57600));
|
||||||
|
timer.Stop();
|
||||||
|
// Expanded owned execution includes called hole programs; Program.ToString() does not,
|
||||||
|
// and rounds coordinates. Hash exact scalar bits in execution order instead.
|
||||||
|
static string Bits(double value) => BitConverter.DoubleToInt64Bits(value).ToString("X16");
|
||||||
|
static string Point(Vector point) => $"{Bits(point.X)},{Bits(point.Y)}";
|
||||||
|
var payload = string.Join("\n", result.ProposedOrder.SelectMany(p =>
|
||||||
|
new[] { $"part:{p.SourceOrdinal}:{Point(p.Location)}:{Bits(p.Rotation)}" }.Concat(
|
||||||
|
p.Execution.Motions.Select(m =>
|
||||||
|
{
|
||||||
|
var geometry = m.Curve?.ToEntity() switch
|
||||||
|
{
|
||||||
|
Arc arc => $"arc:{Point(arc.Center)}:{Bits(arc.Radius)}:{Bits(arc.StartAngle)}:{Bits(arc.EndAngle)}:{arc.Rotation}",
|
||||||
|
Circle circle => $"circle:{Point(circle.Center)}:{Bits(circle.Radius)}:{circle.Rotation}",
|
||||||
|
Line => "line",
|
||||||
|
null => "none",
|
||||||
|
_ => throw new NotSupportedException("Unexpected execution curve."),
|
||||||
|
};
|
||||||
|
return $"{m.Layer}:{m.Rapid}:{(m.Start is { } start ? Point(start) : "null")}:{Point(m.End)}:{geometry}";
|
||||||
|
}))));
|
||||||
|
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(payload)));
|
||||||
|
Console.WriteLine($"Cutting144 elapsedMs={timer.Elapsed.TotalMilliseconds:F1} expansions={result.Expansions} status={result.Status} executionSha256={hash}");
|
||||||
|
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
|
||||||
|
Assert.True(result.IndependentlyReplayed);
|
||||||
|
Assert.Equal(source.OrderBy(Key), result.ProposedOrder.Select(p => p.SourcePart).OrderBy(Key));
|
||||||
|
Assert.Equal(source, plate.Parts);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FreeOrder_BlockedApproach_LearnsToCutThatPartFirst()
|
public void FreeOrder_BlockedApproach_LearnsToCutThatPartFirst()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -147,6 +147,74 @@ public class NestDetailsSessionTests
|
|||||||
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.LoadAsync(Guid.NewGuid()));
|
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.LoadAsync(Guid.NewGuid()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Prefetch_ThenLoad_ResolvesFromTheCacheWithoutASecondRequest()
|
||||||
|
{
|
||||||
|
var requested = new List<Guid>();
|
||||||
|
var details = new NestDetails();
|
||||||
|
using var session = new NestDetailsSession((id, _) =>
|
||||||
|
{
|
||||||
|
requested.Add(id);
|
||||||
|
return Task.FromResult(details);
|
||||||
|
});
|
||||||
|
var nestId = Guid.NewGuid();
|
||||||
|
|
||||||
|
session.Prefetch(nestId);
|
||||||
|
Assert.Equal(new[] { nestId }, requested);
|
||||||
|
|
||||||
|
Assert.True(await session.LoadAsync(nestId));
|
||||||
|
Assert.Same(details, session.Details);
|
||||||
|
Assert.Equal(new[] { nestId }, requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Prefetch_SkipsWhenAlreadyTheCurrentNest_OrAlreadyCached()
|
||||||
|
{
|
||||||
|
var requested = new List<Guid>();
|
||||||
|
using var session = new NestDetailsSession((id, _) =>
|
||||||
|
{
|
||||||
|
requested.Add(id);
|
||||||
|
return Task.FromResult(new NestDetails());
|
||||||
|
});
|
||||||
|
var nestId = Guid.NewGuid();
|
||||||
|
|
||||||
|
await session.LoadAsync(nestId);
|
||||||
|
Assert.Equal(new[] { nestId }, requested);
|
||||||
|
|
||||||
|
// Already the current nest.
|
||||||
|
session.Prefetch(nestId);
|
||||||
|
Assert.Equal(new[] { nestId }, requested);
|
||||||
|
|
||||||
|
// No longer current, but its earlier load is still cached.
|
||||||
|
session.Clear();
|
||||||
|
session.Prefetch(nestId);
|
||||||
|
Assert.Equal(new[] { nestId }, requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Clear_CancelsAnInFlightPrefetch_SoItsResultIsNeverCached()
|
||||||
|
{
|
||||||
|
var loads = new ControlledLoads();
|
||||||
|
using var session = new NestDetailsSession(loads.Load);
|
||||||
|
var nestId = Guid.NewGuid();
|
||||||
|
|
||||||
|
session.Prefetch(nestId);
|
||||||
|
Assert.False(loads.Tokens[0].IsCancellationRequested);
|
||||||
|
|
||||||
|
session.Clear();
|
||||||
|
Assert.True(loads.Tokens[0].IsCancellationRequested);
|
||||||
|
loads.Results[0].SetResult(new NestDetails());
|
||||||
|
|
||||||
|
// Whether or not the cancelled prefetch's continuation has run yet, it must not have
|
||||||
|
// populated the cache, so loading the same nest issues a fresh request.
|
||||||
|
var fresh = new NestDetails();
|
||||||
|
var load = session.LoadAsync(nestId);
|
||||||
|
Assert.Equal(2, loads.Results.Count);
|
||||||
|
loads.Results[1].SetResult(fresh);
|
||||||
|
Assert.True(await load);
|
||||||
|
Assert.Same(fresh, session.Details);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Each load waits for its own result, which the test completes explicitly.</summary>
|
/// <summary>Each load waits for its own result, which the test completes explicitly.</summary>
|
||||||
private sealed class ControlledLoads
|
private sealed class ControlledLoads
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,6 +59,31 @@ public class NestStorageSettingsTests : IDisposable
|
|||||||
Assert.False(settings.IsDatabaseMode);
|
Assert.False(settings.IsDatabaseMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryNormalizeServerUrl_AcceptsHttpsWithoutChangingMode()
|
||||||
|
{
|
||||||
|
var settings = new NestStorageSettings { Mode = NestStorageMode.File };
|
||||||
|
|
||||||
|
Assert.True(NestStorageSettings.TryNormalizeServerUrl(" https://shop.example:8090/ ", out var url));
|
||||||
|
settings.ServerUrl = url;
|
||||||
|
settings.Save(FilePath);
|
||||||
|
|
||||||
|
var loaded = NestStorageSettings.Load(FilePath);
|
||||||
|
Assert.Equal("https://shop.example:8090", loaded.ServerUrl);
|
||||||
|
Assert.Equal(NestStorageMode.File, loaded.Mode);
|
||||||
|
Assert.False(loaded.IsDatabaseMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("ftp://shop.example")]
|
||||||
|
[InlineData("not-a-url")]
|
||||||
|
public void TryNormalizeServerUrl_RejectsInvalidAddress(string input)
|
||||||
|
{
|
||||||
|
Assert.False(NestStorageSettings.TryNormalizeServerUrl(input, out var url));
|
||||||
|
Assert.Equal("", url);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (Directory.Exists(_directory))
|
if (Directory.Exists(_directory))
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Engine;
|
||||||
|
|
||||||
|
public class CutoutPipelinePersistenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ExpandedMultiSheetProposalCommitsAndReloadsAsSeparateDrawings()
|
||||||
|
{
|
||||||
|
var frame = new OpenNest.Shapes.RingShape { OuterDiameter = 20, InnerDiameter = 10 }
|
||||||
|
.GetDrawing();
|
||||||
|
frame.Name = "neutral-frame";
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(6, 0);
|
||||||
|
program.LineTo(6, 6);
|
||||||
|
program.LineTo(0, 6);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
var insert = new Drawing("neutral-insert", program);
|
||||||
|
var request = new NestPipelineRequest("Irregular", new[] {
|
||||||
|
new NestItem { Drawing = frame, Quantity = 2 },
|
||||||
|
new NestItem { Drawing = insert, Quantity = 2 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
|
||||||
|
new NestJobOptions(maxPlates: 2));
|
||||||
|
var ordinary = NestPipeline.Run(request);
|
||||||
|
Assert.Contains(ordinary.Raw.Fulfillment, f => f.Unplaced > 0);
|
||||||
|
var result = NestPipeline.RunCutoutPreview(request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(2, result.Raw.Plates.Count);
|
||||||
|
Assert.All(result.Raw.Plates, p => Assert.Equal(new[] { "part-0", "part-1" },
|
||||||
|
p.Placements.Select(x => x.PartId)));
|
||||||
|
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(frame);
|
||||||
|
nest.Drawings.Add(insert);
|
||||||
|
using (var manager = new PlateManager(nest))
|
||||||
|
{
|
||||||
|
var committed = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
|
||||||
|
Assert.Equal(2, committed.Count);
|
||||||
|
Assert.All(committed, plate => Assert.Equal(new[] { frame, insert },
|
||||||
|
plate.Parts.Select(p => p.BaseDrawing)));
|
||||||
|
}
|
||||||
|
Assert.Equal(2, frame.Quantity.Nested);
|
||||||
|
Assert.Equal(2, insert.Quantity.Nested);
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
Assert.True(new NestWriter(nest).Write(stream));
|
||||||
|
var loaded = new NestReader(new MemoryStream(stream.ToArray())).Read();
|
||||||
|
Assert.Equal(2, loaded.Plates.Count);
|
||||||
|
for (var i = 0; i < loaded.Plates.Count; i++)
|
||||||
|
{
|
||||||
|
var expected = nest.Plates[i];
|
||||||
|
var actual = loaded.Plates[i];
|
||||||
|
Assert.Equal(expected.Size, actual.Size);
|
||||||
|
Assert.Equal(expected.PartSpacing, actual.PartSpacing);
|
||||||
|
Assert.Equal(expected.Quadrant, actual.Quadrant);
|
||||||
|
Assert.Equal(expected.EdgeSpacing, actual.EdgeSpacing);
|
||||||
|
Assert.Equal(new[] { "neutral-frame", "neutral-insert" },
|
||||||
|
actual.Parts.Select(p => p.BaseDrawing.Name));
|
||||||
|
for (var j = 0; j < actual.Parts.Count; j++)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected.Parts[j].Location.X, actual.Parts[j].Location.X, 6);
|
||||||
|
Assert.Equal(expected.Parts[j].Location.Y, actual.Parts[j].Location.Y, 6);
|
||||||
|
Assert.Equal(expected.Parts[j].Rotation, actual.Parts[j].Rotation, 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "neutral-frame").Quantity.Nested);
|
||||||
|
Assert.Equal(2, loaded.Drawings.Single(d => d.Name == "neutral-insert").Quantity.Nested);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -527,6 +527,72 @@ namespace OpenNest.Tests.Fill
|
|||||||
Assert.True(part.BoundingBox.Bottom < 1);
|
Assert.True(part.BoundingBox.Bottom < 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettlePlacement_RepeatsAfterVerticalMovementOpensHorizontalPath()
|
||||||
|
{
|
||||||
|
var plate = new Plate(100, 100);
|
||||||
|
plate.Parts.Add(MakeRectPart(20, 20, 20, 30));
|
||||||
|
var moving = MakeRectPart(60, 35, 10, 10);
|
||||||
|
|
||||||
|
Compactor.SettlePlacement(new List<Part> { moving }, plate,
|
||||||
|
PushDirection.Left, PushDirection.Down);
|
||||||
|
|
||||||
|
Assert.Equal(0, moving.BoundingBox.Left, 6);
|
||||||
|
Assert.Equal(0, moving.BoundingBox.Bottom, 6);
|
||||||
|
Assert.False(moving.Intersects(plate.Parts[0], out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettlePlacement_MovesCopiedGroupTogether()
|
||||||
|
{
|
||||||
|
var plate = new Plate(100, 100);
|
||||||
|
var left = MakeRectPart(40, 40, 5, 5);
|
||||||
|
var right = MakeRectPart(50, 40, 5, 5);
|
||||||
|
var originalGap = right.Location.X - left.Location.X;
|
||||||
|
|
||||||
|
Compactor.SettlePlacement(new List<Part> { left, right }, plate,
|
||||||
|
PushDirection.Left, PushDirection.Down);
|
||||||
|
|
||||||
|
Assert.Equal(0, left.BoundingBox.Left, 6);
|
||||||
|
Assert.Equal(0, left.BoundingBox.Bottom, 6);
|
||||||
|
Assert.Equal(originalGap, right.Location.X - left.Location.X, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettlePlacement_GeometryRepeatsWhenInitialBoxPassIsSkipped()
|
||||||
|
{
|
||||||
|
var plate = new Plate(100, 100);
|
||||||
|
plate.Parts.Add(MakeRectPart(20, 20, 20, 30));
|
||||||
|
// Its bounding box covers the start, but its material is above it.
|
||||||
|
plate.Parts.Add(MakeTrianglePart(new Vector(0, 100),
|
||||||
|
new Vector(100, 100), new Vector(100, 40)));
|
||||||
|
var moving = MakeRectPart(60, 35, 10, 10);
|
||||||
|
|
||||||
|
Compactor.SettlePlacement(new List<Part> { moving }, plate,
|
||||||
|
PushDirection.Left, PushDirection.Down);
|
||||||
|
|
||||||
|
Assert.Equal(0, moving.BoundingBox.Left, 6);
|
||||||
|
Assert.Equal(0, moving.BoundingBox.Bottom, 6);
|
||||||
|
Assert.All(plate.Parts, obstacle => Assert.False(moving.Intersects(obstacle, out _)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettlePlacement_SkipsBoxPassWhenAlreadyInsideObstacleBounds()
|
||||||
|
{
|
||||||
|
// Triangle material sits below the diagonal; the free pocket at (25,25)
|
||||||
|
// is within its bounding box. A box-only slide would falsely block it.
|
||||||
|
var plate = new Plate(100, 100);
|
||||||
|
plate.Parts.Add(MakeTrianglePart(new Vector(0, 0),
|
||||||
|
new Vector(40, 0), new Vector(0, 40)));
|
||||||
|
var moving = MakeRectPart(25, 25, 5, 5);
|
||||||
|
var start = moving.Location;
|
||||||
|
|
||||||
|
Compactor.SettlePlacement(new List<Part> { moving }, plate,
|
||||||
|
PushDirection.Left, PushDirection.Down, maxIterations: 0);
|
||||||
|
|
||||||
|
Assert.Equal(start, moving.Location);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PushBoundingBox_Left_MovesPartTowardEdge()
|
public void PushBoundingBox_Left_MovesPartTowardEdge()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
|
public class PartColorPaletteTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 216, 49, 49)]
|
||||||
|
[InlineData(1, 98, 160, 234)]
|
||||||
|
[InlineData(2, 253, 150, 167)]
|
||||||
|
[InlineData(3, 157, 211, 226)]
|
||||||
|
public void GoldenAngleMatchesWideRangeHslSamples(int index, int r, int g, int b)
|
||||||
|
=> Assert.Equal(Color.FromArgb(r, g, b), PartColorPalette.GoldenAngle(index));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GoldenAngleContinuesPastPaletteLengthAndStaysOutOfEtchBand()
|
||||||
|
{
|
||||||
|
var colors = Enumerable.Range(0, 1000).Select(PartColorPalette.GoldenAngle).ToArray();
|
||||||
|
Assert.NotEqual(colors[0], colors[12]);
|
||||||
|
Assert.All(colors, color =>
|
||||||
|
{
|
||||||
|
Assert.Equal(255, color.A);
|
||||||
|
// The green band belongs to etch strokes; no generated fill may land in it.
|
||||||
|
var hue = color.GetHue();
|
||||||
|
Assert.True(hue < 95 || hue >= 165, $"Fill hue {hue:F0} collides with the etch-green band.");
|
||||||
|
});
|
||||||
|
Assert.Equal(colors, Enumerable.Range(0, 1000).Select(PartColorPalette.GoldenAngle));
|
||||||
|
Assert.InRange(PartColorPalette.GoldenAngle(int.MaxValue).GetBrightness(), 0.4f, 0.85f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NeighboringPartsStayFarApartInRgb()
|
||||||
|
{
|
||||||
|
static double Distance(Color a, Color b) =>
|
||||||
|
System.Math.Sqrt((a.R - b.R) * (double)(a.R - b.R)
|
||||||
|
+ (a.G - b.G) * (a.G - b.G)
|
||||||
|
+ (a.B - b.B) * (a.B - b.B));
|
||||||
|
|
||||||
|
var colors = Enumerable.Range(0, 48).Select(PartColorPalette.GoldenAngle).ToArray();
|
||||||
|
Assert.Equal(48, colors.Select(c => c.ToArgb()).Distinct().Count());
|
||||||
|
for (var i = 0; i < colors.Length - 1; i++)
|
||||||
|
Assert.True(Distance(colors[i], colors[i + 1]) >= 90,
|
||||||
|
$"Parts {i} and {i + 1} are too similar: {Distance(colors[i], colors[i + 1]):F0}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GoldenAngleRejectsNegativeIndices()
|
||||||
|
=> Assert.Throws<ArgumentOutOfRangeException>(() => PartColorPalette.GoldenAngle(-1));
|
||||||
|
}
|
||||||
@@ -28,22 +28,36 @@ public static class ReportPdf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Layout text of one page; skipped where poppler-utils is not installed.</summary>
|
/// <summary>Visual-row text of one page; bbox coordinates avoid platform-specific -layout order.</summary>
|
||||||
public static string Text(string path, int page) =>
|
public static string Text(string path, int page) =>
|
||||||
Regex.Replace(Run("pdftotext", "-layout", "-f", Page(page), "-l", Page(page), path, "-"), "[ \t]+\n", "\n");
|
LayoutPage(Run("pdftotext", "-bbox", "-f", Page(page), "-l", Page(page), path, "-"));
|
||||||
|
|
||||||
/// <summary>Every page's layout text, in order.</summary>
|
/// <summary>Every page's visual-row text, in order.</summary>
|
||||||
public static string[] Pages(string path)
|
public static string[] Pages(string path)
|
||||||
{
|
{
|
||||||
var pages = Run("pdftotext", "-layout", path, "-").Split('\f');
|
var html = Run("pdftotext", "-bbox", path, "-");
|
||||||
// pdftotext terminates the last page with a form feed.
|
return Regex.Matches(html, @"<page\b[^>]*>.*?</page>", RegexOptions.Singleline)
|
||||||
return pages.Take(pages.Length - 1).Select(page => Regex.Replace(page, "[ \t]+\n", "\n")).ToArray();
|
.Select(match => LayoutPage(match.Value)).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LayoutPage(string html)
|
||||||
|
{
|
||||||
|
var rows = new List<(double Top, List<(double Left, string Text)> Words)>();
|
||||||
|
foreach (var word in ParseWords(html).OrderBy(word => word.Top).ThenBy(word => word.Left))
|
||||||
|
{
|
||||||
|
if (rows.Count == 0 || System.Math.Abs(word.Top - rows[^1].Top) > 1.5)
|
||||||
|
rows.Add((word.Top, []));
|
||||||
|
rows[^1].Words.Add((word.Left, word.Text));
|
||||||
|
}
|
||||||
|
return string.Join("\n", rows.Select(row => string.Join(" ", row.Words.OrderBy(word => word.Left).Select(word => word.Text)))) + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Words with page-space boxes (points, Y down).</summary>
|
/// <summary>Words with page-space boxes (points, Y down).</summary>
|
||||||
public static List<(double Left, double Top, double Right, double Bottom, string Text)> Words(string path, int page)
|
public static List<(double Left, double Top, double Right, double Bottom, string Text)> Words(string path, int page) =>
|
||||||
|
ParseWords(Run("pdftotext", "-bbox", "-f", Page(page), "-l", Page(page), path, "-"));
|
||||||
|
|
||||||
|
private static List<(double Left, double Top, double Right, double Bottom, string Text)> ParseWords(string xml)
|
||||||
{
|
{
|
||||||
var xml = Run("pdftotext", "-bbox", "-f", Page(page), "-l", Page(page), path, "-");
|
|
||||||
return Regex.Matches(xml, "<word xMin=\"([\\d.]+)\" yMin=\"([\\d.]+)\" xMax=\"([\\d.]+)\" yMax=\"([\\d.]+)\">([^<]*)</word>")
|
return Regex.Matches(xml, "<word xMin=\"([\\d.]+)\" yMin=\"([\\d.]+)\" xMax=\"([\\d.]+)\" yMax=\"([\\d.]+)\">([^<]*)</word>")
|
||||||
.Select(match => (Number(match.Groups[1].Value), Number(match.Groups[2].Value), Number(match.Groups[3].Value),
|
.Select(match => (Number(match.Groups[1].Value), Number(match.Groups[2].Value), Number(match.Groups[3].Value),
|
||||||
Number(match.Groups[4].Value), System.Net.WebUtility.HtmlDecode(match.Groups[5].Value)))
|
Number(match.Groups[4].Value), System.Net.WebUtility.HtmlDecode(match.Groups[5].Value)))
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Sequencing;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Sequencing;
|
||||||
|
|
||||||
|
public class ManualPartSequencingTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Move_SetsExactOneBasedSequenceWithoutChangingQuantity()
|
||||||
|
{
|
||||||
|
var drawing = TestHelpers.MakeSquareDrawing();
|
||||||
|
var first = new Part(drawing, new Vector(0, 0));
|
||||||
|
var second = new Part(drawing, new Vector(20, 0));
|
||||||
|
var third = new Part(drawing, new Vector(40, 0));
|
||||||
|
var plate = TestHelpers.MakePlate(60, 120, first, second, third);
|
||||||
|
var nested = drawing.Quantity.Nested;
|
||||||
|
var reorders = 0;
|
||||||
|
plate.PartsReordered += (_, _) => reorders++;
|
||||||
|
|
||||||
|
Assert.True(ManualPartSequencing.Move(plate, third, 1));
|
||||||
|
Assert.Equal(new[] { third, first, second }, plate.Parts);
|
||||||
|
Assert.True(ManualPartSequencing.Move(plate, first, 3));
|
||||||
|
Assert.Equal(new[] { third, second, first }, plate.Parts);
|
||||||
|
Assert.Equal(nested, drawing.Quantity.Nested);
|
||||||
|
Assert.Equal(2, reorders);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Move_RejectsAbsentPartAndOutOfRangeWithoutChangingPlate()
|
||||||
|
{
|
||||||
|
var drawing = TestHelpers.MakeSquareDrawing();
|
||||||
|
var part = new Part(drawing, new Vector(0, 0));
|
||||||
|
var plate = TestHelpers.MakePlate(60, 120, part);
|
||||||
|
var reorders = 0;
|
||||||
|
plate.PartsReordered += (_, _) => reorders++;
|
||||||
|
|
||||||
|
Assert.False(ManualPartSequencing.Move(plate, part, 0));
|
||||||
|
Assert.False(ManualPartSequencing.Move(plate, part, 2));
|
||||||
|
Assert.False(ManualPartSequencing.Move(plate, new Part(drawing, new Vector(0, 0)), 1));
|
||||||
|
Assert.Equal(new[] { part }, plate.Parts);
|
||||||
|
Assert.Equal(0, reorders);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Controls;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Program = OpenNest.CNC.Program;
|
||||||
|
|
||||||
|
namespace OpenNest.WinForms.Tests;
|
||||||
|
|
||||||
|
public class EtchDisplayTests
|
||||||
|
{
|
||||||
|
private static readonly Color EtchColor = Color.Lime;
|
||||||
|
private static readonly Color CutColor = Color.FromArgb(48, 48, 48);
|
||||||
|
private static readonly Color PartColor = Color.FromArgb(216, 49, 49);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, false, Mode.Absolute)]
|
||||||
|
[InlineData(false, true, Mode.Absolute)]
|
||||||
|
[InlineData(true, false, Mode.Absolute)]
|
||||||
|
[InlineData(true, true, Mode.Absolute)]
|
||||||
|
[InlineData(false, false, Mode.Incremental)]
|
||||||
|
[InlineData(false, true, Mode.Incremental)]
|
||||||
|
[InlineData(true, false, Mode.Incremental)]
|
||||||
|
[InlineData(true, true, Mode.Incremental)]
|
||||||
|
public void PlateSeparatesEtchFromCutWithoutChangingProgram(bool leads, bool selected, Mode mode)
|
||||||
|
=> StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var program = MixedProgram();
|
||||||
|
program.Mode = mode;
|
||||||
|
var before = Snapshot(program);
|
||||||
|
var drawing = new Drawing("Synthetic etch display", program) { Color = PartColor };
|
||||||
|
var part = new Part(drawing, new Vector(10, 10)) { HasManualLeadIns = leads };
|
||||||
|
using var view = new PlateView(ColorSchemeRegistry.Get("Workshop"));
|
||||||
|
view.Matrix = new Matrix();
|
||||||
|
var layout = LayoutPart.Create(part, view);
|
||||||
|
layout.IsSelected = selected;
|
||||||
|
using var image = new Bitmap(130, 130);
|
||||||
|
using var g = Graphics.FromImage(image);
|
||||||
|
g.Clear(Color.White);
|
||||||
|
layout.Draw(g);
|
||||||
|
|
||||||
|
var renderedFill = image.GetPixel(20, 20);
|
||||||
|
if (!selected)
|
||||||
|
Assert.Equal(PartColor.ToArgb(), renderedFill.ToArgb());
|
||||||
|
else
|
||||||
|
Assert.NotEqual(PartColor.ToArgb(), renderedFill.ToArgb());
|
||||||
|
Assert.True(Luminance(CutColor) < Luminance(renderedFill));
|
||||||
|
Assert.True(Distinctness(EtchColor, renderedFill) >= 15);
|
||||||
|
// A closed etch is not a hole; an actual closed cut still is.
|
||||||
|
Assert.Equal(image.GetPixel(20, 20), image.GetPixel(40, 40));
|
||||||
|
Assert.Equal(Color.White.ToArgb(), image.GetPixel(90, 90).ToArgb());
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), image.GetPixel(30, 40).ToArgb());
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), image.GetPixel(40, 70).ToArgb());
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), image.GetPixel(75, 40).ToArgb());
|
||||||
|
Assert.Equal(CutColor.ToArgb(), image.GetPixel(10, 50).ToArgb());
|
||||||
|
Assert.Equal(CutColor.ToArgb(), image.GetPixel(80, 90).ToArgb());
|
||||||
|
// Suppression must not leave a visible cut or etch stroke.
|
||||||
|
Assert.Equal(image.GetPixel(20, 20), image.GetPixel(40, 90));
|
||||||
|
Assert.Equal(before, Snapshot(program));
|
||||||
|
}, TimeSpan.FromMinutes(1), "Etch display test timed out.");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActualCutWinsWhenEtchOccupiesTheSameLine() => StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var program = MixedProgram();
|
||||||
|
program.MoveTo(20, 60);
|
||||||
|
program.Codes.Add(new LinearMove(40, 60) { Layer = LayerType.Cut });
|
||||||
|
using var view = new PlateView(ColorSchemeRegistry.Get("Workshop"));
|
||||||
|
view.Matrix = new Matrix();
|
||||||
|
var layout = LayoutPart.Create(new Part(new Drawing("Overlaid cut", program)), view);
|
||||||
|
using var image = new Bitmap(120, 120);
|
||||||
|
using var g = Graphics.FromImage(image);
|
||||||
|
layout.Draw(g);
|
||||||
|
Assert.Equal(CutColor.ToArgb(), image.GetPixel(30, 60).ToArgb());
|
||||||
|
}, TimeSpan.FromMinutes(1), "Overlaid cut test timed out.");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ThumbnailKeepsEtchOutOfFillAndUsesDedicatedColor()
|
||||||
|
{
|
||||||
|
var program = MixedProgram();
|
||||||
|
using var fill = new SolidBrush(Color.FromArgb(184, 207, 224));
|
||||||
|
using var image = (Bitmap)program.GetImage(new System.Drawing.Size(110, 110), Pens.Black, fill);
|
||||||
|
Assert.Equal(image.GetPixel(15, 95), image.GetPixel(35, 75));
|
||||||
|
Assert.Equal(0, image.GetPixel(85, 25).A);
|
||||||
|
Assert.Equal(ColorScheme.Default.EtchColor.ToArgb(), image.GetPixel(25, 75).ToArgb());
|
||||||
|
Assert.Equal(Color.Black.ToArgb(), image.GetPixel(75, 25).ToArgb());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlateRendererKeepsEtchesInNumberedPartsAndPlacementPreviews() => StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
using var view = new PlateView(ColorSchemeRegistry.Get("Workshop"))
|
||||||
|
{
|
||||||
|
Size = new System.Drawing.Size(250, 130),
|
||||||
|
DrawBounds = false,
|
||||||
|
};
|
||||||
|
view.Matrix = new Matrix();
|
||||||
|
view.Plate.Parts.Add(new Part(new Drawing("Placed", MixedProgram())));
|
||||||
|
view.Previews.SetActiveParts(new List<Part>
|
||||||
|
{
|
||||||
|
new(new Drawing("Preview", MixedProgram()), new Vector(120, 0)),
|
||||||
|
});
|
||||||
|
using var image = new Bitmap(250, 130);
|
||||||
|
using var g = Graphics.FromImage(image);
|
||||||
|
g.Clear(Color.White);
|
||||||
|
view.Renderer.DrawParts(g);
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), image.GetPixel(20, 30).ToArgb());
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), image.GetPixel(140, 30).ToArgb());
|
||||||
|
Assert.Equal(CutColor.ToArgb(), image.GetPixel(0, 50).ToArgb());
|
||||||
|
}, TimeSpan.FromMinutes(1), "Plate preview test timed out.");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorkshopIsAnAdditionalPaletteNotAReplacementForSavedSchemes()
|
||||||
|
{
|
||||||
|
var scheme = ColorSchemeRegistry.Get("Workshop");
|
||||||
|
Assert.Equal("Workshop", scheme.Name);
|
||||||
|
Assert.Equal("Classic", ColorSchemeRegistry.Get("Classic").Name);
|
||||||
|
Assert.Equal("Pastel", ColorSchemeRegistry.Get("Pastel").Name);
|
||||||
|
Assert.Equal("Dark", ColorSchemeRegistry.Get("Dark").Name);
|
||||||
|
Assert.NotEmpty(scheme.PartColors);
|
||||||
|
Assert.True(scheme.UseGoldenAngleColors);
|
||||||
|
Assert.Equal(PartColor, scheme.PartColors[0]);
|
||||||
|
Assert.Equal(Color.FromArgb(242, 242, 242), scheme.LayoutFillColor);
|
||||||
|
Assert.True(scheme.SelectedPartColor.IsEmpty);
|
||||||
|
Assert.All(Enumerable.Range(0, 1000).Select(PartColorPalette.GoldenAngle), color =>
|
||||||
|
{
|
||||||
|
Assert.True(Distinctness(color, EtchColor) >= 15, "Etch must stay distinguishable from generated fills.");
|
||||||
|
Assert.True(Distinctness(color, CutColor) >= 15, "Cuts must stay distinguishable from generated fills.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SchemeRoundTripAndOldFilesPreserveOutlineFallback()
|
||||||
|
{
|
||||||
|
var workshop = ColorSchemeRegistry.Get("Workshop");
|
||||||
|
var restored = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(workshop));
|
||||||
|
Assert.Equal(CutColor.ToArgb(), restored.PartOutlineColor.ToArgb());
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), restored.EtchColor.ToArgb());
|
||||||
|
Assert.True(restored.SelectedPartColor.IsEmpty);
|
||||||
|
Assert.True(restored.UseGoldenAngleColors);
|
||||||
|
Assert.Equal(workshop.PartColors.Select(c => c.ToArgb()), restored.PartColors.Select(c => c.ToArgb()));
|
||||||
|
var legacy = System.Text.Json.Nodes.JsonNode.Parse(ColorSchemeSerializer.Serialize(ColorSchemeRegistry.Get("Classic")))!.AsObject();
|
||||||
|
legacy.Remove("etchColor");
|
||||||
|
legacy.Remove("partOutlineColor");
|
||||||
|
legacy.Remove("selectedPartColor");
|
||||||
|
legacy.Remove("useGoldenAngleColors");
|
||||||
|
var oldScheme = ColorSchemeSerializer.Deserialize(legacy.ToJsonString());
|
||||||
|
Assert.True(oldScheme.PartOutlineColor.IsEmpty);
|
||||||
|
Assert.True(oldScheme.SelectedPartColor.IsEmpty);
|
||||||
|
Assert.False(oldScheme.UseGoldenAngleColors);
|
||||||
|
Assert.Equal(System.Windows.Forms.ControlPaint.Dark(Color.Coral), oldScheme.GetPartOutlineColor(Color.Coral));
|
||||||
|
Assert.Equal(new ColorScheme().EtchColor.ToArgb(), oldScheme.EtchColor.ToArgb());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PaletteChangeRepaintsExistingPartWithoutMovingIt() => StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var scheme = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorSchemeRegistry.Get("Classic")));
|
||||||
|
var drawing = new Drawing("Recolor", MixedProgram()) { Color = Color.Coral };
|
||||||
|
using var view = new PlateView(scheme);
|
||||||
|
view.Matrix = new Matrix();
|
||||||
|
var layout = LayoutPart.Create(new Part(drawing), view);
|
||||||
|
var path = layout.Path;
|
||||||
|
scheme.PartOutlineColor = CutColor;
|
||||||
|
scheme.EtchColor = EtchColor;
|
||||||
|
drawing.Color = Color.FromArgb(184, 207, 224);
|
||||||
|
using var image = new Bitmap(120, 120);
|
||||||
|
using var g = Graphics.FromImage(image);
|
||||||
|
layout.Draw(g);
|
||||||
|
Assert.Same(path, layout.Path);
|
||||||
|
Assert.Equal(drawing.Color.ToArgb(), image.GetPixel(10, 10).ToArgb());
|
||||||
|
Assert.Equal(CutColor.ToArgb(), image.GetPixel(0, 50).ToArgb());
|
||||||
|
Assert.Equal(EtchColor.ToArgb(), image.GetPixel(20, 30).ToArgb());
|
||||||
|
}, TimeSpan.FromMinutes(1), "Scheme repaint test timed out.");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LayerChangesAndSubprogramsDoNotCreateConnectingStrokes()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(10, 10);
|
||||||
|
program.Codes.Add(new LinearMove(20, 10) { Layer = LayerType.Cut });
|
||||||
|
program.Codes.Add(new LinearMove(30, 10) { Layer = LayerType.Scribe });
|
||||||
|
program.Codes.Add(new LinearMove(40, 10) { Layer = LayerType.Cut });
|
||||||
|
program.Codes.Add(new LinearMove(50, 10) { Layer = LayerType.Leadin });
|
||||||
|
program.Codes.Add(new LinearMove(60, 10) { Layer = LayerType.Leadout });
|
||||||
|
var child = new Program(Mode.Incremental);
|
||||||
|
child.MoveTo(2, 3);
|
||||||
|
child.Codes.Add(new LinearMove(6, 0) { Layer = LayerType.Scribe });
|
||||||
|
program.Codes.Add(new SubProgramCall(child, 0) { Offset = new Vector(70, 20) });
|
||||||
|
var before = Snapshot(program);
|
||||||
|
program.GetDisplayPaths(new Vector(5, 5), out var cuts, out var leads, out var etches);
|
||||||
|
using (cuts)
|
||||||
|
using (leads)
|
||||||
|
using (etches)
|
||||||
|
using (var pen = new Pen(Color.Black, 1))
|
||||||
|
{
|
||||||
|
Assert.True(cuts.IsOutlineVisible(20, 15, pen));
|
||||||
|
Assert.True(cuts.IsOutlineVisible(40, 15, pen));
|
||||||
|
Assert.False(cuts.IsOutlineVisible(30, 15, pen));
|
||||||
|
Assert.True(etches.IsOutlineVisible(30, 15, pen));
|
||||||
|
Assert.False(etches.IsOutlineVisible(40, 15, pen));
|
||||||
|
Assert.True(leads.IsOutlineVisible(50, 15, pen));
|
||||||
|
Assert.True(leads.IsOutlineVisible(60, 15, pen));
|
||||||
|
Assert.False(cuts.IsOutlineVisible(50, 15, pen));
|
||||||
|
Assert.True(etches.IsOutlineVisible(80, 28, pen));
|
||||||
|
Assert.False(etches.IsOutlineVisible(60, 21, pen));
|
||||||
|
}
|
||||||
|
Assert.Equal(before, Snapshot(program));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Contrast(Color a, Color b)
|
||||||
|
{
|
||||||
|
var first = Luminance(a);
|
||||||
|
var second = Luminance(b);
|
||||||
|
return (System.Math.Max(first, second) + 0.05) / (System.Math.Min(first, second) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Luminance(Color color)
|
||||||
|
{
|
||||||
|
static double Linear(byte channel)
|
||||||
|
{
|
||||||
|
var value = channel / 255.0;
|
||||||
|
return value <= 0.04045 ? value / 12.92 : System.Math.Pow((value + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
return 0.2126 * Linear(color.R) + 0.7152 * Linear(color.G) + 0.0722 * Linear(color.B);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Distinctness(Color a, Color b) => System.Math.Sqrt(
|
||||||
|
(a.R - (double)b.R) * (a.R - b.R)
|
||||||
|
+ (a.G - (double)b.G) * (a.G - b.G)
|
||||||
|
+ (a.B - (double)b.B) * (a.B - b.B));
|
||||||
|
|
||||||
|
private static Program MixedProgram()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
AddSquare(program, 0, 0, 100, LayerType.Cut);
|
||||||
|
AddSquare(program, 20, 20, 20, LayerType.Scribe);
|
||||||
|
AddSquare(program, 70, 70, 20, LayerType.Cut);
|
||||||
|
program.MoveTo(20, 60);
|
||||||
|
program.Codes.Add(new LinearMove(40, 60) { Layer = LayerType.Scribe });
|
||||||
|
program.MoveTo(65, 30);
|
||||||
|
program.Codes.Add(new ArcMove(65, 30, 60, 30) { Layer = LayerType.Scribe });
|
||||||
|
program.MoveTo(20, 80);
|
||||||
|
program.Codes.Add(new LinearMove(40, 80) { Layer = LayerType.Scribe, Suppressed = true });
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSquare(Program program, double x, double y, double size, LayerType layer)
|
||||||
|
{
|
||||||
|
program.MoveTo(x, y);
|
||||||
|
foreach (var end in new[] { new Vector(x + size, y), new Vector(x + size, y + size), new Vector(x, y + size), new Vector(x, y) })
|
||||||
|
program.Codes.Add(new LinearMove(end) { Layer = layer });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] Snapshot(Program program) => program.Codes.Select(code => code switch
|
||||||
|
{
|
||||||
|
ArcMove arc => $"arc:{arc.Layer}:{arc.Suppressed}:{arc.EndPoint.X:R}:{arc.EndPoint.Y:R}:{arc.CenterPoint.X:R}:{arc.CenterPoint.Y:R}:{arc.Rotation}",
|
||||||
|
LinearMove line => $"line:{line.Layer}:{line.Suppressed}:{line.EndPoint.X:R}:{line.EndPoint.Y:R}",
|
||||||
|
Motion motion => $"{motion.Type}:{motion.Suppressed}:{motion.EndPoint.X:R}:{motion.EndPoint.Y:R}",
|
||||||
|
_ => code.ToString()!
|
||||||
|
}).Prepend(program.Mode.ToString()).ToArray();
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.Reflection;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Controls;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.WinForms.Tests;
|
||||||
|
|
||||||
|
public class EtchVisibilityTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, false)]
|
||||||
|
[InlineData(false, true)]
|
||||||
|
[InlineData(true, false)]
|
||||||
|
[InlineData(true, true)]
|
||||||
|
public void EtchRemainsVisibleWhenNoCutIntersectsViewport(bool includeCut, bool preview)
|
||||||
|
=> StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var program = new CNC.Program();
|
||||||
|
if (includeCut)
|
||||||
|
AddSquare(program, 0, LayerType.Cut);
|
||||||
|
AddSquare(program, 100, LayerType.Scribe);
|
||||||
|
var part = new Part(new Drawing("Panned etch", program));
|
||||||
|
using var view = new PannedView();
|
||||||
|
if (preview)
|
||||||
|
view.Previews.SetActiveParts(new List<Part> { part });
|
||||||
|
else
|
||||||
|
view.Plate.Parts.Add(part);
|
||||||
|
var layout = preview ? Assert.Single(view.PreviewParts) : Assert.Single(view.LayoutParts);
|
||||||
|
Assert.False(layout.Path.GetBounds().IntersectsWith(view.GetViewBounds()));
|
||||||
|
using var image = new Bitmap(30, 30);
|
||||||
|
using var g = Graphics.FromImage(image);
|
||||||
|
g.Clear(Color.White);
|
||||||
|
g.TranslateTransform(-95, -95);
|
||||||
|
view.Renderer.DrawParts(g);
|
||||||
|
Assert.Equal(Color.Lime.ToArgb(), image.GetPixel(5, 15).ToArgb());
|
||||||
|
Assert.Equal(Color.White.ToArgb(), image.GetPixel(8, 8).ToArgb());
|
||||||
|
if (!includeCut)
|
||||||
|
Assert.Equal(0, layout.Path.PointCount);
|
||||||
|
}, TimeSpan.FromMinutes(1), "Panned etch test timed out.");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EtchOnlyPart_RemainsSelectableAndHoverableWithoutCutMaterial()
|
||||||
|
=> StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var program = new CNC.Program();
|
||||||
|
AddSquare(program, 100, LayerType.Scribe);
|
||||||
|
var part = new Part(new Drawing("etch-only", program));
|
||||||
|
using var view = new PannedView();
|
||||||
|
view.Plate.Parts.Add(part);
|
||||||
|
var layout = Assert.Single(view.LayoutParts);
|
||||||
|
Assert.Equal(0, layout.Path.PointCount);
|
||||||
|
Assert.True(layout.EtchPath.PointCount > 0);
|
||||||
|
|
||||||
|
Assert.Same(layout, view.Selection.GetPartAtGraphPoint(new PointF(110, 110)));
|
||||||
|
Assert.Null(view.Selection.GetPartAtGraphPoint(new PointF(50, 50)));
|
||||||
|
Assert.Contains(layout, view.Selection.GetPartsFromWindow(
|
||||||
|
new RectangleF(105, 105, 10, 10), SelectionType.Intersect));
|
||||||
|
Assert.Contains(layout, view.Selection.GetPartsFromWindow(
|
||||||
|
new RectangleF(95, 95, 30, 30), SelectionType.Contains));
|
||||||
|
|
||||||
|
typeof(PlateView).GetField("hoverPending", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
|
.SetValue(view, true);
|
||||||
|
typeof(PlateView).GetField("hoverPoint", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
|
.SetValue(view, new Point(15, 15));
|
||||||
|
typeof(PlateView).GetMethod("HoverCheck", BindingFlags.Instance | BindingFlags.NonPublic)!
|
||||||
|
.Invoke(view, null);
|
||||||
|
Assert.Same(layout, typeof(PlateView).GetField("hoveredPart",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(view));
|
||||||
|
}, TimeSpan.FromMinutes(1), "Etch selection test timed out.");
|
||||||
|
|
||||||
|
private static void AddSquare(CNC.Program program, double origin, LayerType layer)
|
||||||
|
{
|
||||||
|
program.MoveTo(origin, origin);
|
||||||
|
foreach (var end in new[]
|
||||||
|
{
|
||||||
|
new Vector(origin + 20, origin), new Vector(origin + 20, origin + 20),
|
||||||
|
new Vector(origin, origin + 20), new Vector(origin, origin),
|
||||||
|
})
|
||||||
|
program.Codes.Add(new LinearMove(end) { Layer = layer });
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PannedView : PlateView
|
||||||
|
{
|
||||||
|
public PannedView() : base(ColorSchemeRegistry.Get("Workshop"))
|
||||||
|
{
|
||||||
|
Size = new System.Drawing.Size(30, 30);
|
||||||
|
DrawBounds = false;
|
||||||
|
Matrix = new Matrix();
|
||||||
|
origin = new PointF(-95, -95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,36 @@ namespace OpenNest.WinForms.Tests.Forms;
|
|||||||
|
|
||||||
public class AutoNestFormStockOptionsTests
|
public class AutoNestFormStockOptionsTests
|
||||||
{
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void StockRowsCanBeSelectedAndRemoved(bool loadSavedOptions)
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
using var form = new AutoNestForm(new Nest());
|
||||||
|
if (loadSavedOptions)
|
||||||
|
form.LoadPlateOptions(new List<PlateOption>
|
||||||
|
{
|
||||||
|
new() { Width = 48, Length = 96, Cost = 25 },
|
||||||
|
new() { Width = 60, Length = 120, Cost = 40 },
|
||||||
|
}, 0.5);
|
||||||
|
form.OptimizePlateSize = true;
|
||||||
|
var grid = GetGrid(form);
|
||||||
|
|
||||||
|
Assert.True(grid.RowHeadersVisible);
|
||||||
|
Assert.True(grid.AllowUserToDeleteRows);
|
||||||
|
Assert.Equal(DataGridViewSelectionMode.RowHeaderSelect, grid.SelectionMode);
|
||||||
|
grid.Rows[0].Selected = true;
|
||||||
|
Assert.True(grid.Rows[0].Selected);
|
||||||
|
|
||||||
|
var remaining = form.GetPlateOptions().Count;
|
||||||
|
Assert.IsAssignableFrom<IList>(grid.DataSource).RemoveAt(grid.SelectedRows[0].Index);
|
||||||
|
Assert.Equal(remaining - 1, form.GetPlateOptions().Count);
|
||||||
|
AssertNewRow(grid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(false)]
|
[InlineData(false)]
|
||||||
[InlineData(true)]
|
[InlineData(true)]
|
||||||
|
|||||||
@@ -1,13 +1,216 @@
|
|||||||
using System.Data;
|
using System.Data;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.Forms;
|
using OpenNest.Forms;
|
||||||
using OpenNest.IO.Bom;
|
using OpenNest.IO.Bom;
|
||||||
|
|
||||||
namespace OpenNest.WinForms.Tests.Forms;
|
namespace OpenNest.WinForms.Tests.Forms;
|
||||||
|
|
||||||
|
[Collection("Fill operation lifetime")]
|
||||||
public class BomImportFormTests
|
public class BomImportFormTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FileNameWithDxfExtension_RealRowBuilderShowsReady()
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
using var files = new WindowsAcceptanceFiles();
|
||||||
|
var path = files.WriteSquare("acceptance-square");
|
||||||
|
var row = Assert.Single(BomImportRows.Build(
|
||||||
|
new List<BomItem> { Item("acceptance-square.dxf", qty: 2) }, files.Folder));
|
||||||
|
using var form = Show(out var parts);
|
||||||
|
|
||||||
|
form.LoadRows(new[] { row });
|
||||||
|
|
||||||
|
Assert.True(form.Visible);
|
||||||
|
Assert.Equal(path, row.DxfPath);
|
||||||
|
Assert.Same(row, parts.Rows[0].DataBoundItem);
|
||||||
|
Assert.Equal("acceptance-square.dxf", parts.Rows[0].Cells["colFileName"].Value);
|
||||||
|
Assert.Equal("Ready", parts.Rows[0].Cells["colStatus"].Value);
|
||||||
|
Assert.Equal(BomRowStatus.Ready, row.Status);
|
||||||
|
Assert.Single(GroupsTable(form).Rows);
|
||||||
|
Assert.Equal(2, GroupTotal(form, 0));
|
||||||
|
Assert.True(Field<Button>(form, "btnCreateNests").Enabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BlankThickness_RealRowBuilderResolvesDrawingAndGridEditMakesReadyGroup()
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
using var files = new WindowsAcceptanceFiles();
|
||||||
|
var path = files.WriteSquare("acceptance-square");
|
||||||
|
var item = Item("acceptance-square", qty: 3);
|
||||||
|
item.Thickness = null;
|
||||||
|
var row = Assert.Single(BomImportRows.Build(new List<BomItem> { item }, files.Folder));
|
||||||
|
using var form = Show(out var parts);
|
||||||
|
form.LoadRows(new[] { row });
|
||||||
|
|
||||||
|
Assert.True(form.Visible);
|
||||||
|
Assert.Equal(path, row.DxfPath);
|
||||||
|
Assert.Null(row.Thickness);
|
||||||
|
Assert.True(row.IsEditable);
|
||||||
|
Assert.Equal("Needs thickness", parts.Rows[0].Cells["colStatus"].Value);
|
||||||
|
Assert.Empty(GroupsTable(form).Rows);
|
||||||
|
Assert.False(Field<Button>(form, "btnCreateNests").Enabled);
|
||||||
|
|
||||||
|
Assert.True(Edit(parts, 0, "colThickness", 0.25.ToString()));
|
||||||
|
|
||||||
|
Assert.Equal(0.25, row.Thickness);
|
||||||
|
Assert.Equal(BomRowStatus.Ready, row.Status);
|
||||||
|
Assert.Equal("Ready", parts.Rows[0].Cells["colStatus"].Value);
|
||||||
|
var group = Assert.Single(GroupsTable(form).Rows.Cast<DataRow>());
|
||||||
|
Assert.Equal("Stainless", group["Material"]);
|
||||||
|
Assert.Equal(0.25, group["Thickness"]);
|
||||||
|
Assert.Equal(1, group["Parts"]);
|
||||||
|
Assert.Equal(3, group["Total Qty"]);
|
||||||
|
Assert.Equal("1 ready", Field<Label>(form, "lblSummary").Text);
|
||||||
|
Assert.True(Field<Button>(form, "btnCreateNests").Enabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateRows_CreateNestsButtonImportsOneDrawingWithCombinedQuantity()
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
using var files = new WindowsAcceptanceFiles();
|
||||||
|
var path = files.WriteSquare("acceptance-square");
|
||||||
|
var rows = BomImportRows.Build(
|
||||||
|
new List<BomItem>
|
||||||
|
{
|
||||||
|
Item("acceptance-square", qty: 2),
|
||||||
|
Item("acceptance-square.dxf", qty: 3),
|
||||||
|
}, files.Folder);
|
||||||
|
using var host = new Form { IsMdiContainer = true };
|
||||||
|
host.Show();
|
||||||
|
using var form = Show(out var parts);
|
||||||
|
form.MdiParentForm = host;
|
||||||
|
Field<TextBox>(form, "txtJobName").Text = "Acceptance job";
|
||||||
|
Field<TextBox>(form, "txtPlateWidth").Text = "10";
|
||||||
|
Field<TextBox>(form, "txtPlateLength").Text = "20";
|
||||||
|
form.LoadRows(rows);
|
||||||
|
|
||||||
|
Assert.Equal(2, parts.Rows.Count);
|
||||||
|
Assert.All(rows, row => Assert.Equal(path, row.DxfPath));
|
||||||
|
Assert.Equal(5, GroupTotal(form, 0));
|
||||||
|
var create = Field<Button>(form, "btnCreateNests");
|
||||||
|
Assert.True(create.Enabled);
|
||||||
|
|
||||||
|
// The production event ends in a native MessageBox. A timer in
|
||||||
|
// its modal loop clicks that thread's real OK button, retaining
|
||||||
|
// the actual result text; no builder or success result is stubbed.
|
||||||
|
var dialogTitle = "";
|
||||||
|
var dialogText = "";
|
||||||
|
var okPosted = false;
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||||
|
using var dismiss = new System.Windows.Forms.Timer { Interval = 20 };
|
||||||
|
dismiss.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
EnumThreadWindows(GetCurrentThreadId(), (window, _) =>
|
||||||
|
{
|
||||||
|
var className = new StringBuilder(256);
|
||||||
|
GetClassName(window, className, className.Capacity);
|
||||||
|
if (className.ToString() != "#32770")
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// An OK-only native MessageBox can assign its button the
|
||||||
|
// IDCANCEL id (2), not IDOK (1). Find its sole real button
|
||||||
|
// instead of assuming an id, and keep polling until ready.
|
||||||
|
var ok = FindWindowEx(window, IntPtr.Zero, "Button", null);
|
||||||
|
if (ok == IntPtr.Zero || FindWindowEx(window, ok, "Button", null) != IntPtr.Zero)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
dialogTitle = WindowText(window);
|
||||||
|
dialogText = "";
|
||||||
|
var text = FindWindowEx(window, IntPtr.Zero, "Static", null);
|
||||||
|
while (text != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
var value = WindowText(text);
|
||||||
|
if (!string.IsNullOrEmpty(value))
|
||||||
|
dialogText += value;
|
||||||
|
text = FindWindowEx(window, text, "Static", null);
|
||||||
|
}
|
||||||
|
okPosted = PostMessage(ok, 0x00F5, IntPtr.Zero, IntPtr.Zero);
|
||||||
|
if (okPosted)
|
||||||
|
dismiss.Stop();
|
||||||
|
return !okPosted;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
if (DateTime.UtcNow >= deadline && !okPosted)
|
||||||
|
throw new TimeoutException("The Create Nests result dialog could not be acknowledged.");
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dismiss.Start();
|
||||||
|
create.PerformClick();
|
||||||
|
dismiss.Stop();
|
||||||
|
|
||||||
|
Assert.True(okPosted, "Create Nests did not show its result dialog.");
|
||||||
|
Assert.Equal("Import Complete", dialogTitle);
|
||||||
|
Assert.Equal("1 nest created.", dialogText);
|
||||||
|
Assert.False(form.Visible);
|
||||||
|
var editor = Assert.IsType<EditNestForm>(Assert.Single(host.MdiChildren));
|
||||||
|
Assert.True(editor.Visible);
|
||||||
|
var nest = editor.Nest;
|
||||||
|
var drawing = Assert.Single(nest.Drawings);
|
||||||
|
Assert.Equal("acceptance-square", drawing.Name);
|
||||||
|
Assert.Equal(5, drawing.Quantity.Required);
|
||||||
|
Assert.NotEmpty(drawing.Program.Codes);
|
||||||
|
Assert.Equal(4, drawing.Area, precision: 6);
|
||||||
|
Assert.Equal($"Acceptance job - {0.25:0.####} Stainless", nest.Name);
|
||||||
|
Assert.Equal("Stainless", nest.Material.Name);
|
||||||
|
Assert.Equal(0.25, nest.Thickness);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
dismiss.Stop();
|
||||||
|
foreach (var child in host.MdiChildren)
|
||||||
|
child.Dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BomItem Item(string fileName, int qty) => new()
|
||||||
|
{
|
||||||
|
FileName = fileName,
|
||||||
|
Material = "Stainless",
|
||||||
|
Thickness = 0.25,
|
||||||
|
Qty = qty,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string WindowText(IntPtr window)
|
||||||
|
{
|
||||||
|
var text = new StringBuilder(1024);
|
||||||
|
GetWindowText(window, text, text.Capacity);
|
||||||
|
return text.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private delegate bool EnumThreadWindowCallback(IntPtr window, IntPtr parameter);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
private static extern uint GetCurrentThreadId();
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool EnumThreadWindows(uint threadId, EnumThreadWindowCallback callback, IntPtr parameter);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int GetClassName(IntPtr window, StringBuilder className, int capacity);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int GetWindowText(IntPtr window, StringBuilder text, int capacity);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string className, string? title);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool PostMessage(IntPtr window, uint message, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PartColumns_KeepBomOrderAndOnlyMaterialThicknessAndQtyAreEditable()
|
public void PartColumns_KeepBomOrderAndOnlyMaterialThicknessAndQtyAreEditable()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -227,6 +227,41 @@ public class CuttingPlanFormTests
|
|||||||
Assert.DoesNotContain("Plate 2", text);
|
Assert.DoesNotContain("Plate 2", text);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnverifiedPlan_RequiresAcceptanceAndReplanClearsIt() => RunSta(() =>
|
||||||
|
{
|
||||||
|
var program = SquareProgram();
|
||||||
|
program.MoveTo(2, 2); program.LineTo(4, 2); program.LineTo(4, 4);
|
||||||
|
program.LineTo(2, 4); program.LineTo(2, 2);
|
||||||
|
program.MoveTo(4, 2); program.LineTo(6, 2); program.LineTo(6, 4);
|
||||||
|
program.LineTo(4, 4); program.LineTo(4, 2);
|
||||||
|
var part = new Part(new Drawing("touching contours", program), new Vector(1, 1));
|
||||||
|
var (nest, view) = CreateView(part);
|
||||||
|
using var editor = view;
|
||||||
|
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
|
||||||
|
form.Show();
|
||||||
|
WaitForPlan(form);
|
||||||
|
var accept = Control<CheckBox>(form, "acceptWarningsCheckBox");
|
||||||
|
Assert.True(accept.Visible);
|
||||||
|
Assert.False(accept.Checked);
|
||||||
|
Assert.False(Control<Button>(form, "applyButton").Enabled);
|
||||||
|
Assert.True(Field<PlateView>(form, "preview").Visible);
|
||||||
|
Assert.Contains("UNVERIFIED", Control<Label>(form, "previewLabel").Text);
|
||||||
|
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
|
||||||
|
Assert.Null(form.CommitResult);
|
||||||
|
Assert.False(part.HasManualLeadIns);
|
||||||
|
accept.Checked = true;
|
||||||
|
Assert.True(Control<Button>(form, "applyButton").Enabled);
|
||||||
|
Invoke(form, "PlanButton_Click", null, EventArgs.Empty);
|
||||||
|
WaitForPlan(form);
|
||||||
|
Assert.False(accept.Checked);
|
||||||
|
Assert.False(Control<Button>(form, "applyButton").Enabled);
|
||||||
|
accept.Checked = true;
|
||||||
|
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
|
||||||
|
Assert.Equal(CuttingCommitStatus.Applied, form.CommitResult!.Status);
|
||||||
|
Assert.True(part.HasManualLeadIns);
|
||||||
|
});
|
||||||
|
|
||||||
private static (Nest Nest, PlateView View) CreateView(params Part[] parts)
|
private static (Nest Nest, PlateView View) CreateView(params Part[] parts)
|
||||||
{
|
{
|
||||||
var nest = new Nest();
|
var nest = new Nest();
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using OpenNest.Forms;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.WinForms.Tests.Forms;
|
||||||
|
|
||||||
|
[Collection("Fill operation lifetime")]
|
||||||
|
public class DrawingMetadataColorTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, false)]
|
||||||
|
[InlineData(true, false)]
|
||||||
|
[InlineData(false, true)]
|
||||||
|
[InlineData(true, true)]
|
||||||
|
public void MetadataRefreshKeepsPaletteOwnershipWithoutRebuildingPrograms(
|
||||||
|
bool updateGeometry, bool changeName) => StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var previousScheme = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorScheme.Default));
|
||||||
|
var previousPartColors = Drawing.PartColors;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var program = new CNC.Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(100, 0);
|
||||||
|
program.LineTo(100, 100);
|
||||||
|
program.LineTo(0, 100);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
var drawing = new Drawing("Original", program) { Color = Color.Coral };
|
||||||
|
var nest = new Nest("Metadata palette");
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
var part = new Part(drawing);
|
||||||
|
nest.CreatePlate().Parts.Add(part);
|
||||||
|
using var editor = new EditNestForm(nest);
|
||||||
|
editor.PlateView.SetOverlapAutoCheck(null);
|
||||||
|
editor.Show();
|
||||||
|
editor.PlateView.Matrix.Reset();
|
||||||
|
var layout = Assert.Single(editor.PlateView.LayoutParts);
|
||||||
|
layout.Update(editor.PlateView);
|
||||||
|
var path = layout.Path;
|
||||||
|
var placedProgram = part.Program;
|
||||||
|
var before = NestWriter.GetProgramText(placedProgram);
|
||||||
|
|
||||||
|
using var properties = new EditDrawingForm();
|
||||||
|
properties.LoadDrawing(drawing);
|
||||||
|
if (changeName)
|
||||||
|
Assert.IsType<TextBox>(properties.Controls.Find("nameBox", true).Single()).Text = "Renamed";
|
||||||
|
properties.SaveDrawing(drawing);
|
||||||
|
// The live successful-dialog handler calls this color-only refresh.
|
||||||
|
// Modal interaction itself is covered separately by operator acceptance.
|
||||||
|
editor.RefreshDrawingColor(drawing);
|
||||||
|
|
||||||
|
Assert.Equal(changeName ? "Renamed" : "Original", drawing.Name);
|
||||||
|
Assert.Same(path, layout.Path);
|
||||||
|
Assert.Same(placedProgram, part.Program);
|
||||||
|
Assert.Equal(before, NestWriter.GetProgramText(part.Program));
|
||||||
|
Assert.Equal(Color.Coral, drawing.Color);
|
||||||
|
|
||||||
|
var workshop = ColorSchemeRegistry.Get("Workshop");
|
||||||
|
ColorSchemeRegistry.Apply(workshop);
|
||||||
|
Assert.Equal(workshop.PartColors[0], drawing.Color);
|
||||||
|
if (updateGeometry)
|
||||||
|
layout.Update(editor.PlateView);
|
||||||
|
layout.IsSelected = false;
|
||||||
|
using var image = new Bitmap(120, 120);
|
||||||
|
using var g = Graphics.FromImage(image);
|
||||||
|
layout.Draw(g);
|
||||||
|
Assert.Equal(drawing.Color.ToArgb(), image.GetPixel(20, 20).ToArgb());
|
||||||
|
Assert.Equal(before, NestWriter.GetProgramText(part.Program));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ColorSchemeRegistry.Apply(previousScheme);
|
||||||
|
Drawing.PartColors = previousPartColors;
|
||||||
|
}
|
||||||
|
}, TimeSpan.FromMinutes(1), "Metadata color test timed out.");
|
||||||
|
}
|
||||||
@@ -1,9 +1,127 @@
|
|||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
using System.Windows.Forms;
|
||||||
using OpenNest.Forms;
|
using OpenNest.Forms;
|
||||||
|
|
||||||
namespace OpenNest.WinForms.Tests.Forms;
|
namespace OpenNest.WinForms.Tests.Forms;
|
||||||
|
|
||||||
|
[Collection("Fill operation lifetime")]
|
||||||
public class EditNestInfoFormTests
|
public class EditNestInfoFormTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ShownEditorOk_FileRoundTripPreservesUnchangedMaterialGradeAndDensity()
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
using var files = new WindowsAcceptanceFiles();
|
||||||
|
var nest = AcceptanceNest();
|
||||||
|
|
||||||
|
AcceptShownEditor(nest, materialName: null);
|
||||||
|
|
||||||
|
Assert.Equal("Stainless", nest.Material.Name);
|
||||||
|
Assert.Equal("304", nest.Material.Grade);
|
||||||
|
Assert.Equal(0.289, nest.Material.Density);
|
||||||
|
var restored = files.RoundTrip(nest);
|
||||||
|
Assert.Equal("Stainless", restored.Material.Name);
|
||||||
|
Assert.Equal("304", restored.Material.Grade);
|
||||||
|
Assert.Equal(0.289, restored.Material.Density);
|
||||||
|
Assert.Equal("Acceptance nest", restored.Name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShownEditorOk_FileRoundTripClearsOldMetadataWhenMaterialNameChanges()
|
||||||
|
{
|
||||||
|
RunSta(() =>
|
||||||
|
{
|
||||||
|
using var files = new WindowsAcceptanceFiles();
|
||||||
|
var nest = AcceptanceNest();
|
||||||
|
|
||||||
|
AcceptShownEditor(nest, materialName: "Aluminum");
|
||||||
|
|
||||||
|
Assert.Equal("Aluminum", nest.Material.Name);
|
||||||
|
Assert.True(string.IsNullOrEmpty(nest.Material.Grade));
|
||||||
|
Assert.Equal(0, nest.Material.Density);
|
||||||
|
var restored = files.RoundTrip(nest);
|
||||||
|
Assert.Equal("Aluminum", restored.Material.Name);
|
||||||
|
Assert.True(string.IsNullOrEmpty(restored.Material.Grade));
|
||||||
|
Assert.Equal(0, restored.Material.Density);
|
||||||
|
Assert.Equal("Acceptance nest", restored.Name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Nest AcceptanceNest()
|
||||||
|
{
|
||||||
|
var nest = new Nest("Acceptance nest")
|
||||||
|
{
|
||||||
|
Material = new Material("Stainless", "304", 0.289),
|
||||||
|
Thickness = 0.25,
|
||||||
|
Units = Units.Inches,
|
||||||
|
};
|
||||||
|
nest.PlateDefaults.Size = new Geometry.Size(10, 20);
|
||||||
|
nest.CreatePlate();
|
||||||
|
return nest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AcceptShownEditor(Nest nest, string? materialName)
|
||||||
|
{
|
||||||
|
using var editor = new EditNestForm(nest);
|
||||||
|
editor.Show();
|
||||||
|
Assert.True(editor.Visible);
|
||||||
|
EditNestInfoForm? info = null;
|
||||||
|
Exception? failure = null;
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||||
|
using var accept = new System.Windows.Forms.Timer { Interval = 20 };
|
||||||
|
accept.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
info = Application.OpenForms.OfType<EditNestInfoForm>().SingleOrDefault();
|
||||||
|
if (info == null)
|
||||||
|
{
|
||||||
|
if (DateTime.UtcNow >= deadline)
|
||||||
|
throw new TimeoutException("The Nest Info editor was not shown.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accept.Stop();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(info.Visible);
|
||||||
|
Assert.True(info.Modal);
|
||||||
|
if (materialName != null)
|
||||||
|
Assert.IsType<ComboBox>(info.Controls.Find("materialBox", true).Single()).Text = materialName;
|
||||||
|
info.EnableCheck();
|
||||||
|
var ok = Assert.IsType<Button>(info.Controls.Find("applyButton", true).Single());
|
||||||
|
Assert.True(ok.Enabled);
|
||||||
|
Assert.Equal(DialogResult.OK, ok.DialogResult);
|
||||||
|
ok.PerformClick();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failure = ex;
|
||||||
|
info.DialogResult = DialogResult.Cancel;
|
||||||
|
info.Close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
accept.Start();
|
||||||
|
// This is the live Nest Info entry point; it owns LoadNestInfo,
|
||||||
|
// ShowDialog and SaveNestInfo after the actual OK button accepts.
|
||||||
|
editor.ShowNestInfoEditor();
|
||||||
|
accept.Stop();
|
||||||
|
if (failure != null)
|
||||||
|
ExceptionDispatchInfo.Capture(failure).Throw();
|
||||||
|
Assert.NotNull(info);
|
||||||
|
Assert.Equal(DialogResult.OK, info.DialogResult);
|
||||||
|
Assert.False(info.Visible);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
accept.Stop();
|
||||||
|
info?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SaveWithUnchangedMaterial_KeepsGradeAndDensity()
|
public void SaveWithUnchangedMaterial_KeepsGradeAndDensity()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.WinForms.Tests.Forms;
|
||||||
|
|
||||||
|
/// <summary>Private, synthetic files only; removed after each acceptance case.</summary>
|
||||||
|
internal sealed class WindowsAcceptanceFiles : IDisposable
|
||||||
|
{
|
||||||
|
public string Folder { get; } = Path.Combine(Path.GetTempPath(), "opennest-acceptance-" + Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
public WindowsAcceptanceFiles()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string WriteSquare(string name)
|
||||||
|
{
|
||||||
|
var shape = new Shape();
|
||||||
|
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(2, 0)));
|
||||||
|
shape.Entities.Add(new Line(new Vector(2, 0), new Vector(2, 2)));
|
||||||
|
shape.Entities.Add(new Line(new Vector(2, 2), new Vector(0, 2)));
|
||||||
|
shape.Entities.Add(new Line(new Vector(0, 2), new Vector(0, 0)));
|
||||||
|
var path = Path.Combine(Folder, name + ".dxf");
|
||||||
|
Dxf.ExportProgram(ConvertGeometry.ToProgram(shape), path);
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Nest RoundTrip(Nest nest)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Folder, "acceptance.nest");
|
||||||
|
Assert.True(new NestWriter(nest).Write(path));
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
Assert.True(new FileInfo(path).Length > 0);
|
||||||
|
using var stream = File.OpenRead(path);
|
||||||
|
var reader = new NestReader(stream);
|
||||||
|
var restored = reader.Read();
|
||||||
|
Assert.Empty(reader.Warnings);
|
||||||
|
Assert.NotSame(nest, restored);
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Directory.Delete(Folder, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using OpenNest.Forms;
|
||||||
|
|
||||||
|
namespace OpenNest.WinForms.Tests;
|
||||||
|
|
||||||
|
[Collection("Fill operation lifetime")]
|
||||||
|
public class GoldenAngleColorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void WorkshopRecolorsEachDrawingBeyondAShortPaletteAndKeepsCopiesTogether()
|
||||||
|
=> StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var previous = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorScheme.Default));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nest = new Nest("Generated drawing colors");
|
||||||
|
var cutoff = new Drawing("Cutoff") { IsCutOff = true, Color = Color.Gray };
|
||||||
|
for (var i = 0; i < 24; i++)
|
||||||
|
{
|
||||||
|
var program = new CNC.Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(100, 0);
|
||||||
|
program.LineTo(100, 100);
|
||||||
|
program.LineTo(0, 100);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
nest.Drawings.Add(new Drawing($"Drawing {i}", program) { Color = Color.Coral });
|
||||||
|
if (i == 0)
|
||||||
|
nest.Drawings.Add(cutoff);
|
||||||
|
}
|
||||||
|
var plate = nest.CreatePlate();
|
||||||
|
plate.Parts.Add(new Part(nest.Drawings.First()));
|
||||||
|
plate.Parts.Add(new Part(nest.Drawings.First()));
|
||||||
|
using var editor = new EditNestForm(nest);
|
||||||
|
editor.PlateView.SetOverlapAutoCheck(null);
|
||||||
|
editor.Show();
|
||||||
|
ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Workshop"));
|
||||||
|
|
||||||
|
var drawings = nest.Drawings.Where(d => !d.IsCutOff).ToArray();
|
||||||
|
Assert.Equal(24, drawings.Select(d => d.Color.ToArgb()).Distinct().Count());
|
||||||
|
Assert.Equal(Color.FromArgb(216, 49, 49), drawings[0].Color);
|
||||||
|
Assert.Equal(Color.FromArgb(98, 160, 234), drawings[1].Color);
|
||||||
|
Assert.Equal(Color.Gray, cutoff.Color);
|
||||||
|
Assert.Same(plate.Parts[0].BaseDrawing, plate.Parts[1].BaseDrawing);
|
||||||
|
var colors = drawings.Select(d => d.Color).ToArray();
|
||||||
|
ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Workshop"));
|
||||||
|
Assert.Equal(colors, drawings.Select(d => d.Color));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ColorSchemeRegistry.Apply(previous);
|
||||||
|
}
|
||||||
|
}, TimeSpan.FromMinutes(1), "Generated palette recolor timed out.");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NewlyImportedColorsUseGeneratedHuesAndSwitchBackToLegacyPalettes()
|
||||||
|
=> StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var previous = ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorScheme.Default));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Workshop"));
|
||||||
|
var colors = Enumerable.Range(0, 100).Select(_ => Drawing.GetNextColor()).ToArray();
|
||||||
|
Assert.True(colors.Select(c => c.ToArgb()).Distinct().Count() > 90);
|
||||||
|
Assert.All(colors, color =>
|
||||||
|
{
|
||||||
|
Assert.InRange(color.GetSaturation(), 0.44f, 0.99f);
|
||||||
|
Assert.InRange(color.GetBrightness(), 0.4f, 0.82f);
|
||||||
|
var hue = color.GetHue();
|
||||||
|
Assert.True(hue < 95 || hue >= 165);
|
||||||
|
});
|
||||||
|
var classic = ColorSchemeRegistry.Get("Classic");
|
||||||
|
ColorSchemeRegistry.Apply(classic);
|
||||||
|
Assert.Contains(Drawing.GetNextColor(), classic.PartColors);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ColorSchemeRegistry.Apply(previous);
|
||||||
|
}
|
||||||
|
}, TimeSpan.FromMinutes(1), "Generated import colors timed out.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using OpenNest.Controls;
|
||||||
|
|
||||||
|
namespace OpenNest.WinForms.Tests;
|
||||||
|
|
||||||
|
public class LayoutPartColorTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void BestFitColorOverrideSurvivesPaintAndGeometryUpdate(bool updateGeometry)
|
||||||
|
=> StaTestThread.Run(() =>
|
||||||
|
{
|
||||||
|
var program = new CNC.Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(100, 0);
|
||||||
|
program.LineTo(100, 100);
|
||||||
|
program.LineTo(0, 100);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
var drawing = new Drawing("Best fit color", program) { Color = Color.Coral };
|
||||||
|
using var cell = new BestFitCell(ColorSchemeRegistry.Get("Classic")) { PartColor = Color.Blue };
|
||||||
|
cell.Matrix = new Matrix();
|
||||||
|
cell.Plate.Parts.Add(new Part(drawing));
|
||||||
|
var layout = Assert.Single(cell.LayoutParts);
|
||||||
|
Assert.Equal(Color.Blue, layout.Color);
|
||||||
|
if (updateGeometry)
|
||||||
|
layout.Update(cell);
|
||||||
|
using var image = new Bitmap(120, 120);
|
||||||
|
using var g = Graphics.FromImage(image);
|
||||||
|
layout.Draw(g);
|
||||||
|
Assert.Equal(Color.Blue.ToArgb(), image.GetPixel(20, 20).ToArgb());
|
||||||
|
Assert.Equal(Color.Blue, layout.Color);
|
||||||
|
Assert.Equal(Color.Coral, drawing.Color);
|
||||||
|
|
||||||
|
// The existing explicit refresh returns color ownership to the drawing.
|
||||||
|
layout.Update();
|
||||||
|
layout.Draw(g);
|
||||||
|
Assert.Equal(Color.Coral.ToArgb(), image.GetPixel(20, 20).ToArgb());
|
||||||
|
}, TimeSpan.FromMinutes(1), "Best fit color test timed out.");
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
@@ -178,11 +178,7 @@ namespace OpenNest.Actions
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
Compactor.PushBoundingBox(movingParts, plateView.Plate, hDir);
|
Compactor.SettlePlacement(movingParts, plateView.Plate, hDir, vDir);
|
||||||
Compactor.PushBoundingBox(movingParts, plateView.Plate, vDir);
|
|
||||||
|
|
||||||
Compactor.Push(movingParts, plateView.Plate, hDir);
|
|
||||||
Compactor.Push(movingParts, plateView.Plate, vDir);
|
|
||||||
|
|
||||||
parts.ForEach(p => p.IsDirty = true);
|
parts.ForEach(p => p.IsDirty = true);
|
||||||
plateView.Invalidate();
|
plateView.Invalidate();
|
||||||
|
|||||||
@@ -1,169 +1,101 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Linq;
|
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using OpenNest.Controls;
|
using OpenNest.Controls;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Sequencing;
|
||||||
using OpenNest.Forms;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
|
|
||||||
namespace OpenNest.Actions
|
namespace OpenNest.Actions
|
||||||
{
|
{
|
||||||
[DisplayName("Set Sequence")]
|
[DisplayName("Set Sequence")]
|
||||||
public class ActionSetSequence : Action
|
public class ActionSetSequence : Action
|
||||||
{
|
{
|
||||||
private readonly SequenceForm SequenceForm;
|
private readonly Forms.SequenceForm sequenceForm;
|
||||||
private readonly List<Pair> ShapePartPairs;
|
private readonly Pen pen = new Pen(Color.Blue, 2.0f);
|
||||||
private readonly Pen pen;
|
private LayoutPart hoveredPart;
|
||||||
|
private bool disconnecting;
|
||||||
|
|
||||||
private Shape ClosestShape;
|
// Escape ends this tool; it must not resurrect its closed window.
|
||||||
private Part ClosestPart;
|
public override bool ResumeOnEscape => false;
|
||||||
private int sequenceNumber = 1;
|
|
||||||
|
|
||||||
public int SequenceNumber
|
|
||||||
{
|
|
||||||
get { return sequenceNumber; }
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (
|
|
||||||
value <= SequenceForm.numericUpDown1.Maximum
|
|
||||||
&& value >= SequenceForm.numericUpDown1.Minimum
|
|
||||||
)
|
|
||||||
{
|
|
||||||
sequenceNumber = value;
|
|
||||||
SequenceForm.numericUpDown1.Value = sequenceNumber;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ActionSetSequence(PlateView plateView)
|
public ActionSetSequence(PlateView plateView)
|
||||||
: base(plateView)
|
: base(plateView)
|
||||||
{
|
{
|
||||||
SequenceForm = new Forms.SequenceForm();
|
sequenceForm = new Forms.SequenceForm();
|
||||||
SequenceForm.numericUpDown1.DataBindings.Add(
|
sequenceForm.numericUpDown1.Maximum = System.Math.Max(1, plateView.Plate.Parts.Count);
|
||||||
"Value",
|
sequenceForm.DoneClicked += (_, _) => plateView.EndAction();
|
||||||
this,
|
sequenceForm.FormClosed += (_, _) =>
|
||||||
"SequenceNumber",
|
|
||||||
false,
|
|
||||||
DataSourceUpdateMode.OnPropertyChanged
|
|
||||||
);
|
|
||||||
SequenceForm.numericUpDown1.Maximum = plateView.Plate.Parts.Count;
|
|
||||||
SequenceForm.Owner = Application.OpenForms[0];
|
|
||||||
SequenceForm.Show();
|
|
||||||
plateView.Invalidate();
|
|
||||||
|
|
||||||
pen = new Pen(Color.Blue, 2.0f);
|
|
||||||
|
|
||||||
ShapePartPairs = new List<Pair>();
|
|
||||||
|
|
||||||
foreach (var part in plateView.Plate.Parts)
|
|
||||||
{
|
{
|
||||||
var entities = ConvertProgram
|
if (!disconnecting)
|
||||||
.ToGeometry(part.Program)
|
plateView.EndAction();
|
||||||
.Where(e => e.Layer == SpecialLayers.Cut)
|
};
|
||||||
.ToList();
|
sequenceForm.Show(plateView.FindForm());
|
||||||
entities.ForEach(entity => entity.Offset(part.Location));
|
|
||||||
var shapes = ShapeBuilder.GetShapes(entities);
|
|
||||||
var shape = new Shape();
|
|
||||||
shape.Entities.AddRange(shapes);
|
|
||||||
ShapePartPairs.Add(new Pair() { Part = part, Shape = shape });
|
|
||||||
}
|
|
||||||
|
|
||||||
plateView.MouseMove += plateView_MouseMove;
|
plateView.MouseMove += plateView_MouseMove;
|
||||||
|
plateView.MouseLeave += plateView_MouseLeave;
|
||||||
plateView.MouseClick += plateView_MouseClick;
|
plateView.MouseClick += plateView_MouseClick;
|
||||||
plateView.Paint += plateView_Paint;
|
plateView.Paint += plateView_Paint;
|
||||||
|
|
||||||
SequenceNumber = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void plateView_MouseClick(object sender, MouseEventArgs e)
|
private void plateView_MouseClick(object sender, MouseEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Button == MouseButtons.Right)
|
if (e.Button == MouseButtons.Right)
|
||||||
CancelAction();
|
{
|
||||||
|
plateView.EndAction();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.Button != MouseButtons.Left)
|
if (e.Button != MouseButtons.Left)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (SequenceNumber > plateView.Plate.Parts.Count)
|
// Hit-test at the click, not at the previous mouse-move location.
|
||||||
SequenceNumber = plateView.Plate.Parts.Count;
|
var hit = plateView.GetPartAtControlPoint(e.Location);
|
||||||
|
var part = hit?.BasePart;
|
||||||
if (ClosestPart == null)
|
if (part == null || !ManualPartSequencing.Move(plateView.Plate, part, (int)sequenceForm.numericUpDown1.Value))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
plateView.Plate.Parts.Remove(ClosestPart);
|
if (sequenceForm.numericUpDown1.Value < sequenceForm.numericUpDown1.Maximum)
|
||||||
plateView.Plate.Parts.Insert(SequenceNumber - 1, ClosestPart);
|
sequenceForm.numericUpDown1.Value++;
|
||||||
|
|
||||||
SequenceNumber++;
|
|
||||||
|
|
||||||
plateView.Invalidate();
|
plateView.Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void plateView_MouseMove(object sender, MouseEventArgs e)
|
private void plateView_MouseMove(object sender, MouseEventArgs e)
|
||||||
{
|
{
|
||||||
var pair = GetClosestShape();
|
var hit = plateView.GetPartAtControlPoint(e.Location);
|
||||||
ClosestShape = pair.HasValue ? pair.Value.Shape : null;
|
if (ReferenceEquals(hit, hoveredPart))
|
||||||
ClosestPart = pair.HasValue ? pair.Value.Part : null;
|
return;
|
||||||
|
hoveredPart = hit;
|
||||||
|
plateView.Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void plateView_MouseLeave(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
hoveredPart = null;
|
||||||
plateView.Invalidate();
|
plateView.Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void plateView_Paint(object sender, PaintEventArgs e)
|
private void plateView_Paint(object sender, PaintEventArgs e)
|
||||||
{
|
{
|
||||||
if (ClosestShape == null)
|
var path = hoveredPart?.SelectionPath;
|
||||||
return;
|
if (path != null)
|
||||||
|
e.Graphics.DrawPath(pen, path);
|
||||||
var path = ClosestShape.GetGraphicsPath();
|
|
||||||
path.Transform(plateView.Matrix);
|
|
||||||
e.Graphics.DrawPath(pen, path);
|
|
||||||
path.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Pair? GetClosestShape()
|
|
||||||
{
|
|
||||||
Pair? closestShape = null;
|
|
||||||
double distance = double.MaxValue;
|
|
||||||
|
|
||||||
for (int i = 0; i < ShapePartPairs.Count; i++)
|
|
||||||
{
|
|
||||||
var shape = ShapePartPairs[i];
|
|
||||||
var closestPt = shape.Shape.ClosestPointTo(plateView.CurrentPoint);
|
|
||||||
var distance2 = closestPt.DistanceTo(plateView.CurrentPoint);
|
|
||||||
|
|
||||||
if (distance2 < distance)
|
|
||||||
{
|
|
||||||
closestShape = shape;
|
|
||||||
distance = distance2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return closestShape;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void DisconnectEvents()
|
public override void DisconnectEvents()
|
||||||
{
|
{
|
||||||
|
disconnecting = true;
|
||||||
plateView.Paint -= plateView_Paint;
|
plateView.Paint -= plateView_Paint;
|
||||||
plateView.MouseMove -= plateView_MouseMove;
|
plateView.MouseMove -= plateView_MouseMove;
|
||||||
|
plateView.MouseLeave -= plateView_MouseLeave;
|
||||||
plateView.MouseClick -= plateView_MouseClick;
|
plateView.MouseClick -= plateView_MouseClick;
|
||||||
|
hoveredPart = null;
|
||||||
|
sequenceForm.Close();
|
||||||
|
sequenceForm.Dispose();
|
||||||
|
pen.Dispose();
|
||||||
plateView.Invalidate();
|
plateView.Invalidate();
|
||||||
|
|
||||||
SequenceForm.Close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void CancelAction()
|
public override void CancelAction() { }
|
||||||
{
|
|
||||||
SequenceNumber = 1;
|
|
||||||
SequenceForm.numericUpDown1.Value = SequenceNumber + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool IsBusy()
|
public override bool IsBusy() => false;
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct Pair
|
|
||||||
{
|
|
||||||
public Part Part;
|
|
||||||
public Shape Shape;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+14
-1
@@ -1,4 +1,4 @@
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Drawing.Drawing2D;
|
using System.Drawing.Drawing2D;
|
||||||
|
|
||||||
namespace OpenNest
|
namespace OpenNest
|
||||||
@@ -17,6 +17,19 @@ namespace OpenNest
|
|||||||
|
|
||||||
public Color[] PartColors { get; set; } = Drawing.PartColors;
|
public Color[] PartColors { get; set; } = Drawing.PartColors;
|
||||||
|
|
||||||
|
public bool UseGoldenAngleColors { get; set; }
|
||||||
|
|
||||||
|
// Empty preserves the legacy outline derived from each part's fill.
|
||||||
|
public Color PartOutlineColor { get; set; } = Color.Empty;
|
||||||
|
|
||||||
|
// Empty preserves the legacy translucent selection fill.
|
||||||
|
public Color SelectedPartColor { get; set; } = Color.Empty;
|
||||||
|
|
||||||
|
public Color EtchColor { get; set; } = Color.Lime;
|
||||||
|
|
||||||
|
public Color GetPartOutlineColor(Color fill) =>
|
||||||
|
PartOutlineColor.IsEmpty ? System.Windows.Forms.ControlPaint.Dark(fill) : PartOutlineColor;
|
||||||
|
|
||||||
public static readonly ColorScheme Default = new ColorScheme
|
public static readonly ColorScheme Default = new ColorScheme
|
||||||
{
|
{
|
||||||
Name = "Classic",
|
Name = "Classic",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ namespace OpenNest
|
|||||||
["Classic"] = BuildClassic(),
|
["Classic"] = BuildClassic(),
|
||||||
["Pastel"] = BuildPastel(),
|
["Pastel"] = BuildPastel(),
|
||||||
["Dark"] = BuildDark(),
|
["Dark"] = BuildDark(),
|
||||||
|
["Workshop"] = BuildWorkshop(),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static List<ColorScheme> diskCache;
|
private static List<ColorScheme> diskCache;
|
||||||
@@ -64,13 +65,18 @@ namespace OpenNest
|
|||||||
d.EdgeSpacingColor = scheme.EdgeSpacingColor;
|
d.EdgeSpacingColor = scheme.EdgeSpacingColor;
|
||||||
d.PreviewPartColor = scheme.PreviewPartColor;
|
d.PreviewPartColor = scheme.PreviewPartColor;
|
||||||
d.PartColors = scheme.PartColors;
|
d.PartColors = scheme.PartColors;
|
||||||
|
d.UseGoldenAngleColors = scheme.UseGoldenAngleColors;
|
||||||
|
d.PartOutlineColor = scheme.PartOutlineColor;
|
||||||
|
d.SelectedPartColor = scheme.SelectedPartColor;
|
||||||
|
d.EtchColor = scheme.EtchColor;
|
||||||
|
|
||||||
Drawing.PartColors = scheme.PartColors;
|
Drawing.PartColors = scheme.PartColors;
|
||||||
|
Drawing.UseGoldenAngleColors = scheme.UseGoldenAngleColors;
|
||||||
|
|
||||||
RecolorOpenNests(scheme.PartColors);
|
RecolorOpenNests();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RecolorOpenNests(Color[] palette)
|
private static void RecolorOpenNests()
|
||||||
{
|
{
|
||||||
foreach (Form f in Application.OpenForms)
|
foreach (Form f in Application.OpenForms)
|
||||||
{
|
{
|
||||||
@@ -82,7 +88,7 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
if (drawing.IsCutOff)
|
if (drawing.IsCutOff)
|
||||||
continue;
|
continue;
|
||||||
drawing.Color = palette[i % palette.Length];
|
drawing.Color = Drawing.GetPartColor(i);
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +116,25 @@ namespace OpenNest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ColorScheme BuildWorkshop() =>
|
||||||
|
new ColorScheme
|
||||||
|
{
|
||||||
|
Name = "Workshop",
|
||||||
|
BackgroundColor = Color.FromArgb(205, 208, 212),
|
||||||
|
LayoutOutlineColor = Color.FromArgb(145, 150, 156),
|
||||||
|
LayoutFillColor = Color.FromArgb(242, 242, 242),
|
||||||
|
BoundingBoxColor = Color.FromArgb(95, 125, 155),
|
||||||
|
RapidColor = Color.FromArgb(65, 115, 165),
|
||||||
|
OriginColor = Color.FromArgb(110, 115, 120),
|
||||||
|
EdgeSpacingColor = Color.FromArgb(185, 190, 196),
|
||||||
|
PreviewPartColor = Color.FromArgb(100, 145, 180),
|
||||||
|
PartOutlineColor = Color.FromArgb(48, 48, 48),
|
||||||
|
EtchColor = Color.Lime,
|
||||||
|
UseGoldenAngleColors = true,
|
||||||
|
// Samples for previews/legacy consumers; assignment uses the full index.
|
||||||
|
PartColors = Enumerable.Range(0, 12).Select(PartColorPalette.GoldenAngle).ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
private static ColorScheme BuildClassic() =>
|
private static ColorScheme BuildClassic() =>
|
||||||
new ColorScheme
|
new ColorScheme
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ namespace OpenNest
|
|||||||
OriginColor = ToHex(scheme.OriginColor),
|
OriginColor = ToHex(scheme.OriginColor),
|
||||||
EdgeSpacingColor = ToHex(scheme.EdgeSpacingColor),
|
EdgeSpacingColor = ToHex(scheme.EdgeSpacingColor),
|
||||||
PreviewPartColor = ToHex(scheme.PreviewPartColor),
|
PreviewPartColor = ToHex(scheme.PreviewPartColor),
|
||||||
|
PartOutlineColor = scheme.PartOutlineColor.IsEmpty ? null : ToHex(scheme.PartOutlineColor),
|
||||||
|
SelectedPartColor = scheme.SelectedPartColor.IsEmpty ? null : ToHex(scheme.SelectedPartColor),
|
||||||
|
EtchColor = ToHex(scheme.EtchColor),
|
||||||
PartColors = scheme.PartColors.Select(ToHex).ToArray(),
|
PartColors = scheme.PartColors.Select(ToHex).ToArray(),
|
||||||
|
UseGoldenAngleColors = scheme.UseGoldenAngleColors,
|
||||||
};
|
};
|
||||||
return JsonSerializer.Serialize(dto, JsonOptions);
|
return JsonSerializer.Serialize(dto, JsonOptions);
|
||||||
}
|
}
|
||||||
@@ -48,7 +52,11 @@ namespace OpenNest
|
|||||||
OriginColor = FromHex(dto.OriginColor),
|
OriginColor = FromHex(dto.OriginColor),
|
||||||
EdgeSpacingColor = FromHex(dto.EdgeSpacingColor),
|
EdgeSpacingColor = FromHex(dto.EdgeSpacingColor),
|
||||||
PreviewPartColor = FromHex(dto.PreviewPartColor),
|
PreviewPartColor = FromHex(dto.PreviewPartColor),
|
||||||
|
PartOutlineColor = string.IsNullOrWhiteSpace(dto.PartOutlineColor) ? Color.Empty : FromHex(dto.PartOutlineColor),
|
||||||
|
SelectedPartColor = string.IsNullOrWhiteSpace(dto.SelectedPartColor) ? Color.Empty : FromHex(dto.SelectedPartColor),
|
||||||
|
EtchColor = string.IsNullOrWhiteSpace(dto.EtchColor) ? Color.Lime : FromHex(dto.EtchColor),
|
||||||
PartColors = (dto.PartColors ?? new string[0]).Select(FromHex).ToArray(),
|
PartColors = (dto.PartColors ?? new string[0]).Select(FromHex).ToArray(),
|
||||||
|
UseGoldenAngleColors = dto.UseGoldenAngleColors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +99,11 @@ namespace OpenNest
|
|||||||
public string OriginColor { get; set; }
|
public string OriginColor { get; set; }
|
||||||
public string EdgeSpacingColor { get; set; }
|
public string EdgeSpacingColor { get; set; }
|
||||||
public string PreviewPartColor { get; set; }
|
public string PreviewPartColor { get; set; }
|
||||||
|
public string PartOutlineColor { get; set; }
|
||||||
|
public string SelectedPartColor { get; set; }
|
||||||
|
public string EtchColor { get; set; }
|
||||||
public string[] PartColors { get; set; }
|
public string[] PartColors { get; set; }
|
||||||
|
public bool UseGoldenAngleColors { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
using Size = System.Drawing.Size;
|
||||||
|
|
||||||
namespace OpenNest.Controls
|
namespace OpenNest.Controls
|
||||||
{
|
{
|
||||||
using Size = System.Drawing.Size;
|
|
||||||
|
|
||||||
public class DrawingListBox : ListBox
|
public class DrawingListBox : ListBox
|
||||||
{
|
{
|
||||||
private const int WM_ERASEBKGND = 0x0014;
|
private const int WM_ERASEBKGND = 0x0014;
|
||||||
@@ -98,9 +97,9 @@ namespace OpenNest.Controls
|
|||||||
var pt = new PointF(5, e.Bounds.Y + 5);
|
var pt = new PointF(5, e.Bounds.Y + 5);
|
||||||
|
|
||||||
var brush = new SolidBrush(dwg.Color);
|
var brush = new SolidBrush(dwg.Color);
|
||||||
var pen = new Pen(ControlPaint.Dark(dwg.Color));
|
var pen = new Pen(ColorScheme.Default.GetPartOutlineColor(dwg.Color));
|
||||||
|
|
||||||
var img = dwg.Program.GetImage(imageSize, pen, brush);
|
using var img = dwg.Program.GetImage(imageSize, pen, brush);
|
||||||
|
|
||||||
pen.Dispose();
|
pen.Dispose();
|
||||||
brush.Dispose();
|
brush.Dispose();
|
||||||
|
|||||||
@@ -118,10 +118,7 @@ namespace OpenNest.Controls
|
|||||||
if (part.IsDirty)
|
if (part.IsDirty)
|
||||||
part.Update(view);
|
part.Update(view);
|
||||||
|
|
||||||
var path = part.Path;
|
if (!part.DisplayBounds.IntersectsWith(viewBounds))
|
||||||
var pathBounds = path.GetBounds();
|
|
||||||
|
|
||||||
if (!pathBounds.IntersectsWith(viewBounds))
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
part.Draw(g, (i + 1).ToString());
|
part.Draw(g, (i + 1).ToString());
|
||||||
@@ -142,10 +139,11 @@ namespace OpenNest.Controls
|
|||||||
part.Update(view);
|
part.Update(view);
|
||||||
|
|
||||||
var path = part.Path;
|
var path = part.Path;
|
||||||
if (!path.GetBounds().IntersectsWith(viewBounds))
|
if (!part.DisplayBounds.IntersectsWith(viewBounds))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
g.FillPath(previewBrush, path);
|
g.FillPath(previewBrush, path);
|
||||||
|
part.DrawEtch(g);
|
||||||
g.DrawPath(previewPen, path);
|
g.DrawPath(previewPen, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -816,7 +816,8 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
for (var i = parts.Count - 1; i >= 0; --i)
|
for (var i = parts.Count - 1; i >= 0; --i)
|
||||||
{
|
{
|
||||||
if (parts[i].Path.GetBounds().Contains(graphPt) && parts[i].Path.IsVisible(graphPt))
|
var path = parts[i].SelectionPath;
|
||||||
|
if (path?.GetBounds().Contains(graphPt) == true && path.IsVisible(graphPt))
|
||||||
{
|
{
|
||||||
hitPart = parts[i];
|
hitPart = parts[i];
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ namespace OpenNest.Controls
|
|||||||
var parts = view.LayoutParts;
|
var parts = view.LayoutParts;
|
||||||
for (var i = parts.Count - 1; i >= 0; --i)
|
for (var i = parts.Count - 1; i >= 0; --i)
|
||||||
{
|
{
|
||||||
if (parts[i].Path.IsVisible(pt))
|
if (parts[i].SelectionPath?.IsVisible(pt) == true)
|
||||||
return parts[i];
|
return parts[i];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -230,10 +230,12 @@ namespace OpenNest.Controls
|
|||||||
for (var i = 0; i < parts.Count; ++i)
|
for (var i = 0; i < parts.Count; ++i)
|
||||||
{
|
{
|
||||||
var part = parts[i];
|
var part = parts[i];
|
||||||
var region = new Region(part.Path);
|
var path = part.SelectionPath;
|
||||||
|
if (path == null)
|
||||||
|
continue;
|
||||||
|
using var region = new Region(path);
|
||||||
if (region.IsVisible(rect))
|
if (region.IsVisible(rect))
|
||||||
list.Add(part);
|
list.Add(part);
|
||||||
region.Dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -241,8 +243,8 @@ namespace OpenNest.Controls
|
|||||||
for (var i = 0; i < parts.Count; ++i)
|
for (var i = 0; i < parts.Count; ++i)
|
||||||
{
|
{
|
||||||
var part = parts[i];
|
var part = parts[i];
|
||||||
var bounds = part.Path.GetBounds();
|
var path = part.SelectionPath;
|
||||||
if (rect.Contains(bounds))
|
if (path != null && rect.Contains(path.GetBounds()))
|
||||||
list.Add(part);
|
list.Add(part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-1
@@ -173,7 +173,7 @@ namespace OpenNest.Forms
|
|||||||
this.plateGrid.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
|
this.plateGrid.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
|
||||||
this.plateGrid.Location = new System.Drawing.Point(10, 22);
|
this.plateGrid.Location = new System.Drawing.Point(10, 22);
|
||||||
this.plateGrid.Name = "plateGrid";
|
this.plateGrid.Name = "plateGrid";
|
||||||
this.plateGrid.RowHeadersVisible = false;
|
this.plateGrid.RowHeadersVisible = true;
|
||||||
this.plateGrid.AutoGenerateColumns = false;
|
this.plateGrid.AutoGenerateColumns = false;
|
||||||
this.plateGrid.Size = new System.Drawing.Size(508, 130);
|
this.plateGrid.Size = new System.Drawing.Size(508, 130);
|
||||||
this.plateGrid.TabIndex = 1;
|
this.plateGrid.TabIndex = 1;
|
||||||
|
|||||||
+17
-3
@@ -25,6 +25,7 @@ partial class CuttingPlanForm
|
|||||||
settingsButton = new Button();
|
settingsButton = new Button();
|
||||||
optionsPanel = new FlowLayoutPanel();
|
optionsPanel = new FlowLayoutPanel();
|
||||||
keepOrderCheckBox = new CheckBox();
|
keepOrderCheckBox = new CheckBox();
|
||||||
|
acceptWarningsCheckBox = new CheckBox();
|
||||||
planButton = new Button();
|
planButton = new Button();
|
||||||
split = new SplitContainer();
|
split = new SplitContainer();
|
||||||
previewLayout = new TableLayoutPanel();
|
previewLayout = new TableLayoutPanel();
|
||||||
@@ -54,15 +55,17 @@ partial class CuttingPlanForm
|
|||||||
layout.Controls.Add(optionsPanel, 0, 1);
|
layout.Controls.Add(optionsPanel, 0, 1);
|
||||||
layout.Controls.Add(split, 0, 2);
|
layout.Controls.Add(split, 0, 2);
|
||||||
layout.Controls.Add(statusLabel, 0, 3);
|
layout.Controls.Add(statusLabel, 0, 3);
|
||||||
layout.Controls.Add(buttonsPanel, 0, 4);
|
layout.Controls.Add(acceptWarningsCheckBox, 0, 4);
|
||||||
|
layout.Controls.Add(buttonsPanel, 0, 5);
|
||||||
layout.Dock = DockStyle.Fill;
|
layout.Dock = DockStyle.Fill;
|
||||||
layout.Name = "layout";
|
layout.Name = "layout";
|
||||||
layout.RowCount = 5;
|
layout.RowCount = 6;
|
||||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||||
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
|
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
|
||||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||||
|
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||||
layout.TabIndex = 0;
|
layout.TabIndex = 0;
|
||||||
//
|
//
|
||||||
// settingsPanel
|
// settingsPanel
|
||||||
@@ -190,6 +193,16 @@ partial class CuttingPlanForm
|
|||||||
statusLabel.Name = "statusLabel";
|
statusLabel.Name = "statusLabel";
|
||||||
statusLabel.TabIndex = 3;
|
statusLabel.TabIndex = 3;
|
||||||
//
|
//
|
||||||
|
// acceptWarningsCheckBox
|
||||||
|
//
|
||||||
|
acceptWarningsCheckBox.AutoSize = true;
|
||||||
|
acceptWarningsCheckBox.Dock = DockStyle.Fill;
|
||||||
|
acceptWarningsCheckBox.Name = "acceptWarningsCheckBox";
|
||||||
|
acceptWarningsCheckBox.Text = "I reviewed the warnings. Apply this unverified plan.";
|
||||||
|
acceptWarningsCheckBox.Visible = false;
|
||||||
|
acceptWarningsCheckBox.TabIndex = 4;
|
||||||
|
acceptWarningsCheckBox.CheckedChanged += AcceptWarningsCheckBox_CheckedChanged;
|
||||||
|
//
|
||||||
// buttonsPanel
|
// buttonsPanel
|
||||||
//
|
//
|
||||||
buttonsPanel.AutoSize = true;
|
buttonsPanel.AutoSize = true;
|
||||||
@@ -199,7 +212,7 @@ partial class CuttingPlanForm
|
|||||||
buttonsPanel.FlowDirection = FlowDirection.RightToLeft;
|
buttonsPanel.FlowDirection = FlowDirection.RightToLeft;
|
||||||
buttonsPanel.Margin = new Padding(0, 8, 0, 0);
|
buttonsPanel.Margin = new Padding(0, 8, 0, 0);
|
||||||
buttonsPanel.Name = "buttonsPanel";
|
buttonsPanel.Name = "buttonsPanel";
|
||||||
buttonsPanel.TabIndex = 4;
|
buttonsPanel.TabIndex = 5;
|
||||||
buttonsPanel.WrapContents = false;
|
buttonsPanel.WrapContents = false;
|
||||||
//
|
//
|
||||||
// applyButton
|
// applyButton
|
||||||
@@ -264,6 +277,7 @@ partial class CuttingPlanForm
|
|||||||
private Button settingsButton;
|
private Button settingsButton;
|
||||||
private FlowLayoutPanel optionsPanel;
|
private FlowLayoutPanel optionsPanel;
|
||||||
private CheckBox keepOrderCheckBox;
|
private CheckBox keepOrderCheckBox;
|
||||||
|
private CheckBox acceptWarningsCheckBox;
|
||||||
private Button planButton;
|
private Button planButton;
|
||||||
private SplitContainer split;
|
private SplitContainer split;
|
||||||
private TableLayoutPanel previewLayout;
|
private TableLayoutPanel previewLayout;
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ public partial class CuttingPlanForm : Form
|
|||||||
this.isOperationBusy = isOperationBusy;
|
this.isOperationBusy = isOperationBusy;
|
||||||
unit = UnitsHelper.GetShortString(nest.Units);
|
unit = UnitsHelper.GetShortString(nest.Units);
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
// Best-effort acceptance must be able to display every warning, even on large jobs.
|
||||||
|
summaryBox.MaxLength = int.MaxValue;
|
||||||
// Captured once: when Application.DoEvents ends the outermost message loop, WinForms
|
// Captured once: when Application.DoEvents ends the outermost message loop, WinForms
|
||||||
// uninstalls its ambient context, so progress and results must not depend on whichever
|
// uninstalls its ambient context, so progress and results must not depend on whichever
|
||||||
// context is current when planning starts.
|
// context is current when planning starts.
|
||||||
@@ -241,7 +243,11 @@ public partial class CuttingPlanForm : Form
|
|||||||
{
|
{
|
||||||
proposal = result;
|
proposal = result;
|
||||||
summaryBox.Text = string.Join(Environment.NewLine, result.Describe(unit));
|
summaryBox.Text = string.Join(Environment.NewLine, result.Describe(unit));
|
||||||
statusLabel.Text = result.CanApply ? "Review the plan, then apply it." : "Nothing can be applied.";
|
acceptWarningsCheckBox.Checked = false;
|
||||||
|
acceptWarningsCheckBox.Visible = result.RequiresWarningAcceptance;
|
||||||
|
statusLabel.Text = result.CanApply ? "Review the plan, then apply it."
|
||||||
|
: result.RequiresWarningAcceptance ? "Unverified plan: review and accept the warnings to apply."
|
||||||
|
: "Nothing can be applied.";
|
||||||
applyButton.Enabled = result.CanApply;
|
applyButton.Enabled = result.CanApply;
|
||||||
var index = Array.IndexOf(plates, activePlate);
|
var index = Array.IndexOf(plates, activePlate);
|
||||||
var plate = index < 0 ? null : result.BuildPreview(index);
|
var plate = index < 0 ? null : result.BuildPreview(index);
|
||||||
@@ -252,7 +258,7 @@ public partial class CuttingPlanForm : Form
|
|||||||
+ "match the editor.";
|
+ "match the editor.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
previewLabel.Text = previewText;
|
previewLabel.Text = result.Plates[index].IsReady ? previewText : "UNVERIFIED — " + previewText;
|
||||||
preview.Plate = plate;
|
preview.Plate = plate;
|
||||||
preview.Visible = true;
|
preview.Visible = true;
|
||||||
preview.ZoomToFit();
|
preview.ZoomToFit();
|
||||||
@@ -261,6 +267,8 @@ public partial class CuttingPlanForm : Form
|
|||||||
private void ClearProposal()
|
private void ClearProposal()
|
||||||
{
|
{
|
||||||
proposal = null;
|
proposal = null;
|
||||||
|
acceptWarningsCheckBox.Checked = false;
|
||||||
|
acceptWarningsCheckBox.Visible = false;
|
||||||
applyButton.Enabled = false;
|
applyButton.Enabled = false;
|
||||||
preview.Visible = false;
|
preview.Visible = false;
|
||||||
previewLabel.Text = previewText;
|
previewLabel.Text = previewText;
|
||||||
@@ -277,6 +285,7 @@ public partial class CuttingPlanForm : Form
|
|||||||
{
|
{
|
||||||
settingsButton.Enabled = !running;
|
settingsButton.Enabled = !running;
|
||||||
keepOrderCheckBox.Enabled = !running;
|
keepOrderCheckBox.Enabled = !running;
|
||||||
|
acceptWarningsCheckBox.Enabled = !running;
|
||||||
planButton.Enabled = !running;
|
planButton.Enabled = !running;
|
||||||
if (running)
|
if (running)
|
||||||
applyButton.Enabled = false;
|
applyButton.Enabled = false;
|
||||||
@@ -334,9 +343,15 @@ public partial class CuttingPlanForm : Form
|
|||||||
public void Report(CuttingPlanProgress value) => context.Post(_ => handler(value), null);
|
public void Report(CuttingPlanProgress value) => context.Post(_ => handler(value), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanApplyProposal => proposal?.CanApply == true
|
||||||
|
|| proposal?.CanApplyWithWarnings == true && acceptWarningsCheckBox.Checked;
|
||||||
|
|
||||||
|
private void AcceptWarningsCheckBox_CheckedChanged(object sender, EventArgs e) =>
|
||||||
|
applyButton.Enabled = planning == null && CanApplyProposal;
|
||||||
|
|
||||||
private void ApplyButton_Click(object sender, EventArgs e)
|
private void ApplyButton_Click(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (planning != null || proposal?.CanApply != true)
|
if (planning != null || !CanApplyProposal)
|
||||||
return;
|
return;
|
||||||
if (!TryCheckCanChange(out var reason))
|
if (!TryCheckCanChange(out var reason))
|
||||||
{
|
{
|
||||||
@@ -344,7 +359,7 @@ public partial class CuttingPlanForm : Form
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var commit = proposal.Apply();
|
var commit = proposal.Apply(acceptWarningsCheckBox.Checked);
|
||||||
CommitResult = commit;
|
CommitResult = commit;
|
||||||
switch (commit.Status)
|
switch (commit.Status)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
|
||||||
namespace OpenNest.Forms
|
namespace OpenNest.Forms
|
||||||
@@ -45,7 +45,8 @@ namespace OpenNest.Forms
|
|||||||
private void UpdateImage()
|
private void UpdateImage()
|
||||||
{
|
{
|
||||||
var brush = new SolidBrush(colorDialog1.Color);
|
var brush = new SolidBrush(colorDialog1.Color);
|
||||||
var pen = new Pen(ControlPaint.Dark(colorDialog1.Color));
|
var pen = new Pen(ColorScheme.Default.GetPartOutlineColor(colorDialog1.Color));
|
||||||
|
DrawingImage?.Dispose();
|
||||||
DrawingImage = drawing.Program.GetImage(pictureBox1.Size, pen, brush);
|
DrawingImage = drawing.Program.GetImage(pictureBox1.Size, pen, brush);
|
||||||
|
|
||||||
pen.Dispose();
|
pen.Dispose();
|
||||||
|
|||||||
@@ -1248,16 +1248,21 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
form.SaveDrawing(drawing);
|
form.SaveDrawing(drawing);
|
||||||
|
|
||||||
// Metadata edits only refresh color; never rebuild the placed part program.
|
RefreshDrawingColor(drawing);
|
||||||
foreach (var layoutPart in PlateView.Parts)
|
|
||||||
if (ReferenceEquals(layoutPart.BasePart.BaseDrawing, drawing))
|
|
||||||
layoutPart.Color = drawing.Color;
|
|
||||||
|
|
||||||
UpdateDrawingList();
|
UpdateDrawingList();
|
||||||
PlateView.Invalidate();
|
PlateView.Invalidate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void RefreshDrawingColor(Drawing drawing)
|
||||||
|
{
|
||||||
|
// Metadata edits only refresh color; never rebuild the placed part program.
|
||||||
|
foreach (var layoutPart in PlateView.Parts)
|
||||||
|
if (ReferenceEquals(layoutPart.BasePart.BaseDrawing, drawing))
|
||||||
|
layoutPart.Update();
|
||||||
|
}
|
||||||
|
|
||||||
private void drawingListBox1_DeleteRequested(object sender, Drawing drawing)
|
private void drawingListBox1_DeleteRequested(object sender, Drawing drawing)
|
||||||
{
|
{
|
||||||
var result = MessageBox.Show(
|
var result = MessageBox.Show(
|
||||||
|
|||||||
@@ -646,6 +646,7 @@ namespace OpenNest.Forms
|
|||||||
var serverUrl = NestStorage.Settings.ServerUrl;
|
var serverUrl = NestStorage.Settings.ServerUrl;
|
||||||
databaseOpenInProgress = true;
|
databaseOpenInProgress = true;
|
||||||
storageModeMenu.Enabled = false;
|
storageModeMenu.Enabled = false;
|
||||||
|
mnuToolsOptions.Enabled = false;
|
||||||
var file = await repository.GetFileAsync(dlg.SelectedId);
|
var file = await repository.GetFileAsync(dlg.SelectedId);
|
||||||
if (file == null)
|
if (file == null)
|
||||||
throw new FileNotFoundException("This nest no longer exists on the server.");
|
throw new FileNotFoundException("This nest no longer exists on the server.");
|
||||||
@@ -663,7 +664,10 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
databaseOpenInProgress = false;
|
databaseOpenInProgress = false;
|
||||||
if (!IsDisposed)
|
if (!IsDisposed)
|
||||||
|
{
|
||||||
storageModeMenu.Enabled = !databaseSaveInProgress;
|
storageModeMenu.Enabled = !databaseSaveInProgress;
|
||||||
|
mnuToolsOptions.Enabled = !databaseSaveInProgress;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,6 +711,7 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
databaseSaveInProgress = true;
|
databaseSaveInProgress = true;
|
||||||
storageModeMenu.Enabled = false;
|
storageModeMenu.Enabled = false;
|
||||||
|
mnuToolsOptions.Enabled = false;
|
||||||
EnableCheck();
|
EnableCheck();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -729,6 +734,7 @@ namespace OpenNest.Forms
|
|||||||
if (!IsDisposed)
|
if (!IsDisposed)
|
||||||
{
|
{
|
||||||
storageModeMenu.Enabled = !databaseOpenInProgress;
|
storageModeMenu.Enabled = !databaseOpenInProgress;
|
||||||
|
mnuToolsOptions.Enabled = !databaseOpenInProgress;
|
||||||
EnableCheck();
|
EnableCheck();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+42
-3
@@ -41,9 +41,13 @@
|
|||||||
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
|
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
|
||||||
this.colorSchemeLabel = new System.Windows.Forms.Label();
|
this.colorSchemeLabel = new System.Windows.Forms.Label();
|
||||||
this.colorSchemeCombo = new System.Windows.Forms.ComboBox();
|
this.colorSchemeCombo = new System.Windows.Forms.ComboBox();
|
||||||
|
this.serverGroupBox = new System.Windows.Forms.GroupBox();
|
||||||
|
this.serverUrlBox = new System.Windows.Forms.TextBox();
|
||||||
|
this.setServerButton = new System.Windows.Forms.Button();
|
||||||
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
|
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
|
||||||
this.tableLayoutPanel1.SuspendLayout();
|
this.tableLayoutPanel1.SuspendLayout();
|
||||||
this.bottomPanel1.SuspendLayout();
|
this.bottomPanel1.SuspendLayout();
|
||||||
|
this.serverGroupBox.SuspendLayout();
|
||||||
this.SuspendLayout();
|
this.SuspendLayout();
|
||||||
//
|
//
|
||||||
// checkBox1
|
// checkBox1
|
||||||
@@ -135,7 +139,7 @@
|
|||||||
this.bottomPanel1.Controls.Add(this.cancelButton);
|
this.bottomPanel1.Controls.Add(this.cancelButton);
|
||||||
this.bottomPanel1.Controls.Add(this.saveButton);
|
this.bottomPanel1.Controls.Add(this.saveButton);
|
||||||
this.bottomPanel1.Dock = System.Windows.Forms.DockStyle.Bottom;
|
this.bottomPanel1.Dock = System.Windows.Forms.DockStyle.Bottom;
|
||||||
this.bottomPanel1.Location = new System.Drawing.Point(0, 368);
|
this.bottomPanel1.Location = new System.Drawing.Point(0, 443);
|
||||||
this.bottomPanel1.Name = "bottomPanel1";
|
this.bottomPanel1.Name = "bottomPanel1";
|
||||||
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
|
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
|
||||||
this.bottomPanel1.TabIndex = 1;
|
this.bottomPanel1.TabIndex = 1;
|
||||||
@@ -158,7 +162,7 @@
|
|||||||
// strategyGroupBox
|
// strategyGroupBox
|
||||||
//
|
//
|
||||||
this.strategyGroupBox.Controls.Add(this.strategyGrid);
|
this.strategyGroupBox.Controls.Add(this.strategyGrid);
|
||||||
this.strategyGroupBox.Location = new System.Drawing.Point(12, 178);
|
this.strategyGroupBox.Location = new System.Drawing.Point(12, 253);
|
||||||
this.strategyGroupBox.Name = "strategyGroupBox";
|
this.strategyGroupBox.Name = "strategyGroupBox";
|
||||||
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
|
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
|
||||||
this.strategyGroupBox.TabIndex = 2;
|
this.strategyGroupBox.TabIndex = 2;
|
||||||
@@ -184,13 +188,43 @@
|
|||||||
this.colorSchemeCombo.Size = new System.Drawing.Size(130, 24);
|
this.colorSchemeCombo.Size = new System.Drawing.Size(130, 24);
|
||||||
this.colorSchemeCombo.TabIndex = 11;
|
this.colorSchemeCombo.TabIndex = 11;
|
||||||
//
|
//
|
||||||
|
// serverGroupBox
|
||||||
|
//
|
||||||
|
this.serverGroupBox.Controls.Add(this.serverUrlBox);
|
||||||
|
this.serverGroupBox.Controls.Add(this.setServerButton);
|
||||||
|
this.serverGroupBox.Location = new System.Drawing.Point(12, 178);
|
||||||
|
this.serverGroupBox.Name = "serverGroupBox";
|
||||||
|
this.serverGroupBox.Size = new System.Drawing.Size(684, 69);
|
||||||
|
this.serverGroupBox.TabIndex = 1;
|
||||||
|
this.serverGroupBox.TabStop = false;
|
||||||
|
this.serverGroupBox.Text = "Nest server URL (File / Database mode is selected under File > Storage Mode)";
|
||||||
|
//
|
||||||
|
// serverUrlBox
|
||||||
|
//
|
||||||
|
this.serverUrlBox.Location = new System.Drawing.Point(12, 29);
|
||||||
|
this.serverUrlBox.Name = "serverUrlBox";
|
||||||
|
this.serverUrlBox.PlaceholderText = "http://server:8090";
|
||||||
|
this.serverUrlBox.Size = new System.Drawing.Size(485, 22);
|
||||||
|
this.serverUrlBox.TabIndex = 0;
|
||||||
|
//
|
||||||
|
// setServerButton
|
||||||
|
//
|
||||||
|
this.setServerButton.Location = new System.Drawing.Point(504, 26);
|
||||||
|
this.setServerButton.Name = "setServerButton";
|
||||||
|
this.setServerButton.Size = new System.Drawing.Size(168, 28);
|
||||||
|
this.setServerButton.TabIndex = 1;
|
||||||
|
this.setServerButton.Text = "Set / Change Server";
|
||||||
|
this.setServerButton.UseVisualStyleBackColor = true;
|
||||||
|
this.setServerButton.Click += new System.EventHandler(this.SetServer_Click);
|
||||||
|
//
|
||||||
// OptionsForm
|
// OptionsForm
|
||||||
//
|
//
|
||||||
this.AcceptButton = this.saveButton;
|
this.AcceptButton = this.saveButton;
|
||||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
||||||
this.CancelButton = this.cancelButton;
|
this.CancelButton = this.cancelButton;
|
||||||
this.ClientSize = new System.Drawing.Size(708, 418);
|
this.ClientSize = new System.Drawing.Size(708, 493);
|
||||||
this.Controls.Add(this.strategyGroupBox);
|
this.Controls.Add(this.strategyGroupBox);
|
||||||
|
this.Controls.Add(this.serverGroupBox);
|
||||||
this.Controls.Add(this.tableLayoutPanel1);
|
this.Controls.Add(this.tableLayoutPanel1);
|
||||||
this.Controls.Add(this.bottomPanel1);
|
this.Controls.Add(this.bottomPanel1);
|
||||||
this.Font = new System.Drawing.Font("Microsoft Sans Serif", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
|
this.Font = new System.Drawing.Font("Microsoft Sans Serif", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
|
||||||
@@ -206,6 +240,8 @@
|
|||||||
this.tableLayoutPanel1.ResumeLayout(false);
|
this.tableLayoutPanel1.ResumeLayout(false);
|
||||||
this.tableLayoutPanel1.PerformLayout();
|
this.tableLayoutPanel1.PerformLayout();
|
||||||
this.bottomPanel1.ResumeLayout(false);
|
this.bottomPanel1.ResumeLayout(false);
|
||||||
|
this.serverGroupBox.ResumeLayout(false);
|
||||||
|
this.serverGroupBox.PerformLayout();
|
||||||
this.ResumeLayout(false);
|
this.ResumeLayout(false);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -224,5 +260,8 @@
|
|||||||
private System.Windows.Forms.GroupBox strategyGroupBox;
|
private System.Windows.Forms.GroupBox strategyGroupBox;
|
||||||
private System.Windows.Forms.Label colorSchemeLabel;
|
private System.Windows.Forms.Label colorSchemeLabel;
|
||||||
private System.Windows.Forms.ComboBox colorSchemeCombo;
|
private System.Windows.Forms.ComboBox colorSchemeCombo;
|
||||||
|
private System.Windows.Forms.GroupBox serverGroupBox;
|
||||||
|
private System.Windows.Forms.TextBox serverUrlBox;
|
||||||
|
private System.Windows.Forms.Button setServerButton;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
using OpenNest.Data;
|
||||||
using OpenNest.Engine.Strategies;
|
using OpenNest.Engine.Strategies;
|
||||||
using OpenNest.Properties;
|
using OpenNest.Properties;
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
private void LoadSettings()
|
private void LoadSettings()
|
||||||
{
|
{
|
||||||
|
serverUrlBox.Text = NestStorage.Settings.ServerUrl;
|
||||||
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
|
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
|
||||||
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
|
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
|
||||||
|
|
||||||
@@ -142,5 +144,28 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
SaveSettings();
|
SaveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetServer_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (!NestStorageSettings.TryNormalizeServerUrl(serverUrlBox.Text, out var url))
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "Enter a valid http or https server URL.",
|
||||||
|
"Server URL", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
NestStorage.Save(NestStorage.Settings.Mode, url);
|
||||||
|
serverUrlBox.Text = NestStorage.Settings.ServerUrl;
|
||||||
|
MessageBox.Show(this, "Server URL saved. Storage mode has not changed.",
|
||||||
|
"Server URL", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, $"Could not save the server URL: {ex.Message}",
|
||||||
|
"Server URL", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,11 @@ namespace OpenNest.Forms;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SavedNestsForm : Form
|
public sealed class SavedNestsForm : Form
|
||||||
{
|
{
|
||||||
|
// Rows this far before/after the highlight are warmed in the background once the
|
||||||
|
// highlighted nest's own details are showing, so arrowing through the page mostly
|
||||||
|
// hits the NestDetailsSession cache instead of downloading each nest on demand.
|
||||||
|
private const int PrefetchWindow = 5;
|
||||||
|
|
||||||
private const string IdColumn = "Id";
|
private const string IdColumn = "Id";
|
||||||
private const string PlateSizeColumn = "PlateSize";
|
private const string PlateSizeColumn = "PlateSize";
|
||||||
private const string AreaColumn = "Area";
|
private const string AreaColumn = "Area";
|
||||||
@@ -571,6 +576,7 @@ public sealed class SavedNestsForm : Form
|
|||||||
if (details.Details != null)
|
if (details.Details != null)
|
||||||
{
|
{
|
||||||
PopulateDetails(details.Details);
|
PopulateDetails(details.Details);
|
||||||
|
PrefetchNearby(id.Value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +596,10 @@ public sealed class SavedNestsForm : Form
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (await details.LoadAsync(id) && !IsDisposed)
|
if (await details.LoadAsync(id) && !IsDisposed)
|
||||||
|
{
|
||||||
PopulateDetails(details.Details);
|
PopulateDetails(details.Details);
|
||||||
|
PrefetchNearby(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -599,6 +608,41 @@ public sealed class SavedNestsForm : Form
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warms the cache for the rows around the highlighted one (<see cref="PrefetchWindow"/>
|
||||||
|
/// on each side) so arrowing to a nearby nest resolves from <see cref="NestDetailsSession"/>'s
|
||||||
|
/// cache instead of a fresh download. Only the current page's rows are candidates.
|
||||||
|
/// </summary>
|
||||||
|
private void PrefetchNearby(Guid id)
|
||||||
|
{
|
||||||
|
var index = -1;
|
||||||
|
for (var i = 0; i < nestGrid.Rows.Count; i++)
|
||||||
|
{
|
||||||
|
if (nestGrid.Rows[i].Cells[IdColumn].Value is Guid rowId && rowId == id)
|
||||||
|
{
|
||||||
|
index = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (var offset = 1; offset <= PrefetchWindow; offset++)
|
||||||
|
{
|
||||||
|
PrefetchRow(index - offset);
|
||||||
|
PrefetchRow(index + offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PrefetchRow(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= nestGrid.Rows.Count)
|
||||||
|
return;
|
||||||
|
if (nestGrid.Rows[index].Cells[IdColumn].Value is Guid id)
|
||||||
|
details.Prefetch(id);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Downloads one nest's archive and reads its plates and drawings off the UI thread.</summary>
|
/// <summary>Downloads one nest's archive and reads its plates and drawings off the UI thread.</summary>
|
||||||
private async Task<NestDetails> LoadDetailsAsync(Guid id, CancellationToken cancellationToken)
|
private async Task<NestDetails> LoadDetailsAsync(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+17
-5
@@ -31,6 +31,7 @@
|
|||||||
tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel();
|
tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel();
|
||||||
label1 = new System.Windows.Forms.Label();
|
label1 = new System.Windows.Forms.Label();
|
||||||
numericUpDown1 = new OpenNest.Controls.NumericUpDown();
|
numericUpDown1 = new OpenNest.Controls.NumericUpDown();
|
||||||
|
doneButton = new System.Windows.Forms.Button();
|
||||||
tableLayoutPanel1.SuspendLayout();
|
tableLayoutPanel1.SuspendLayout();
|
||||||
((System.ComponentModel.ISupportInitialize)numericUpDown1).BeginInit();
|
((System.ComponentModel.ISupportInitialize)numericUpDown1).BeginInit();
|
||||||
SuspendLayout();
|
SuspendLayout();
|
||||||
@@ -42,13 +43,15 @@
|
|||||||
tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
|
tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
|
||||||
tableLayoutPanel1.Controls.Add(label1, 0, 0);
|
tableLayoutPanel1.Controls.Add(label1, 0, 0);
|
||||||
tableLayoutPanel1.Controls.Add(numericUpDown1, 1, 0);
|
tableLayoutPanel1.Controls.Add(numericUpDown1, 1, 0);
|
||||||
|
tableLayoutPanel1.Controls.Add(doneButton, 1, 1);
|
||||||
tableLayoutPanel1.Dock = System.Windows.Forms.DockStyle.Fill;
|
tableLayoutPanel1.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||||
tableLayoutPanel1.Location = new System.Drawing.Point(0, 0);
|
tableLayoutPanel1.Location = new System.Drawing.Point(0, 0);
|
||||||
tableLayoutPanel1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
tableLayoutPanel1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||||
tableLayoutPanel1.Name = "tableLayoutPanel1";
|
tableLayoutPanel1.Name = "tableLayoutPanel1";
|
||||||
tableLayoutPanel1.RowCount = 1;
|
tableLayoutPanel1.RowCount = 2;
|
||||||
|
tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Absolute, 35F));
|
||||||
tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
|
tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
|
||||||
tableLayoutPanel1.Size = new System.Drawing.Size(266, 35);
|
tableLayoutPanel1.Size = new System.Drawing.Size(266, 74);
|
||||||
tableLayoutPanel1.TabIndex = 0;
|
tableLayoutPanel1.TabIndex = 0;
|
||||||
//
|
//
|
||||||
// label1
|
// label1
|
||||||
@@ -74,20 +77,28 @@
|
|||||||
numericUpDown1.TabIndex = 1;
|
numericUpDown1.TabIndex = 1;
|
||||||
numericUpDown1.Value = new decimal(new int[] { 1, 0, 0, 0 });
|
numericUpDown1.Value = new decimal(new int[] { 1, 0, 0, 0 });
|
||||||
numericUpDown1.Leave += numericUpDown1_Leave;
|
numericUpDown1.Leave += numericUpDown1_Leave;
|
||||||
|
// doneButton
|
||||||
//
|
//
|
||||||
|
doneButton.Anchor = System.Windows.Forms.AnchorStyles.Right;
|
||||||
|
doneButton.Location = new System.Drawing.Point(182, 40);
|
||||||
|
doneButton.Name = "doneButton";
|
||||||
|
doneButton.Size = new System.Drawing.Size(80, 28);
|
||||||
|
doneButton.TabIndex = 2;
|
||||||
|
doneButton.Text = "Done";
|
||||||
|
doneButton.UseVisualStyleBackColor = true;
|
||||||
|
doneButton.Click += doneButton_Click;
|
||||||
// SequenceForm
|
// SequenceForm
|
||||||
//
|
//
|
||||||
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||||
ClientSize = new System.Drawing.Size(266, 35);
|
ClientSize = new System.Drawing.Size(266, 74);
|
||||||
ControlBox = false;
|
|
||||||
Controls.Add(tableLayoutPanel1);
|
Controls.Add(tableLayoutPanel1);
|
||||||
FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow;
|
FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow;
|
||||||
Location = new System.Drawing.Point(100, 100);
|
Location = new System.Drawing.Point(100, 100);
|
||||||
Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||||
MaximizeBox = false;
|
MaximizeBox = false;
|
||||||
MinimizeBox = false;
|
MinimizeBox = false;
|
||||||
MinimumSize = new System.Drawing.Size(268, 74);
|
MinimumSize = new System.Drawing.Size(268, 113);
|
||||||
Name = "SequenceForm";
|
Name = "SequenceForm";
|
||||||
ShowIcon = false;
|
ShowIcon = false;
|
||||||
ShowInTaskbar = false;
|
ShowInTaskbar = false;
|
||||||
@@ -105,5 +116,6 @@
|
|||||||
private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1;
|
private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1;
|
||||||
private System.Windows.Forms.Label label1;
|
private System.Windows.Forms.Label label1;
|
||||||
public Controls.NumericUpDown numericUpDown1;
|
public Controls.NumericUpDown numericUpDown1;
|
||||||
|
private System.Windows.Forms.Button doneButton;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
|
||||||
namespace OpenNest.Forms
|
namespace OpenNest.Forms
|
||||||
@@ -10,6 +10,13 @@ namespace OpenNest.Forms
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public event EventHandler DoneClicked;
|
||||||
|
|
||||||
|
private void doneButton_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
DoneClicked?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
private void numericUpDown1_Leave(object sender, EventArgs e)
|
private void numericUpDown1_Leave(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
numericUpDown1.Validate();
|
numericUpDown1.Validate();
|
||||||
|
|||||||
+54
-14
@@ -39,6 +39,33 @@ namespace OpenNest
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void GetDisplayPaths(
|
||||||
|
this Program pgm,
|
||||||
|
Vector origin,
|
||||||
|
out GraphicsPath cutPath,
|
||||||
|
out GraphicsPath leadPath,
|
||||||
|
out GraphicsPath etchPath
|
||||||
|
)
|
||||||
|
{
|
||||||
|
cutPath = GetLayerPath(pgm, origin,
|
||||||
|
layer => layer != LayerType.Scribe && layer != LayerType.Leadin && layer != LayerType.Leadout);
|
||||||
|
leadPath = GetLayerPath(pgm, origin,
|
||||||
|
layer => layer == LayerType.Leadin || layer == LayerType.Leadout);
|
||||||
|
etchPath = GetLayerPath(pgm, origin, layer => layer == LayerType.Scribe);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GraphicsPath GetLayerPath(Program pgm, Vector origin, System.Func<LayerType, bool> includeLayer)
|
||||||
|
{
|
||||||
|
var path = new GraphicsPath();
|
||||||
|
var position = Vector.Zero;
|
||||||
|
AddProgram(path, pgm, pgm.Mode, ref position, includeLayer);
|
||||||
|
// Place the completed local path; absolute moves must not discard placement.
|
||||||
|
using var translation = new Matrix();
|
||||||
|
translation.Translate((float)origin.X, (float)origin.Y);
|
||||||
|
path.Transform(translation);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
public static Image GetImage(this Program pgm, System.Drawing.Size size)
|
public static Image GetImage(this Program pgm, System.Drawing.Size size)
|
||||||
{
|
{
|
||||||
return pgm.GetImage(size, Pens.Black, null);
|
return pgm.GetImage(size, Pens.Black, null);
|
||||||
@@ -57,26 +84,34 @@ namespace OpenNest
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var img = new Bitmap(size.Width, size.Height);
|
var img = new Bitmap(size.Width, size.Height);
|
||||||
var path = pgm.GetGraphicsPath();
|
pgm.GetDisplayPaths(Vector.Zero, out var cuts, out var leads, out var etches);
|
||||||
var bounds = path.GetBounds();
|
using var path = cuts;
|
||||||
|
using var leadPath = leads;
|
||||||
|
using var etchPath = etches;
|
||||||
|
var bounds = path.PointCount > 0 ? path.GetBounds() : etchPath.GetBounds();
|
||||||
|
if (path.PointCount > 0 && etchPath.PointCount > 0)
|
||||||
|
bounds = RectangleF.Union(bounds, etchPath.GetBounds());
|
||||||
|
|
||||||
var scalex = (size.Height - 10) / bounds.Height;
|
var scalex = (size.Height - 10) / System.Math.Max(bounds.Height, 0.001f);
|
||||||
var scaley = (size.Width - 10) / bounds.Width;
|
var scaley = (size.Width - 10) / System.Math.Max(bounds.Width, 0.001f);
|
||||||
var scale = scalex < scaley ? scalex : scaley;
|
var scale = scalex < scaley ? scalex : scaley;
|
||||||
|
|
||||||
var matrix = new Matrix();
|
using var matrix = new Matrix();
|
||||||
matrix.Scale(scale, -scale);
|
matrix.Scale(scale, -scale);
|
||||||
|
|
||||||
path.Transform(matrix);
|
path.Transform(matrix);
|
||||||
|
etchPath.Transform(matrix);
|
||||||
|
|
||||||
bounds = path.GetBounds();
|
bounds = path.PointCount > 0 ? path.GetBounds() : etchPath.GetBounds();
|
||||||
|
if (path.PointCount > 0 && etchPath.PointCount > 0)
|
||||||
|
bounds = RectangleF.Union(bounds, etchPath.GetBounds());
|
||||||
|
|
||||||
var offset = new PointF(
|
var offset = new PointF(
|
||||||
(size.Width - bounds.Width) * 0.5f - bounds.X,
|
(size.Width - bounds.Width) * 0.5f - bounds.X,
|
||||||
(size.Height - bounds.Height) * 0.5f - bounds.Y
|
(size.Height - bounds.Height) * 0.5f - bounds.Y
|
||||||
);
|
);
|
||||||
|
|
||||||
var graphics = Graphics.FromImage(img);
|
using var graphics = Graphics.FromImage(img);
|
||||||
graphics.TranslateTransform(offset.X, offset.Y);
|
graphics.TranslateTransform(offset.X, offset.Y);
|
||||||
|
|
||||||
if (brush != null)
|
if (brush != null)
|
||||||
@@ -85,11 +120,11 @@ namespace OpenNest
|
|||||||
if (pen == null)
|
if (pen == null)
|
||||||
pen = Pens.Black;
|
pen = Pens.Black;
|
||||||
|
|
||||||
|
using var etchPen = new Pen(ColorScheme.Default.EtchColor, 1.5f);
|
||||||
|
graphics.DrawPath(etchPen, etchPath);
|
||||||
|
// A coincident cut must remain visible over an etch.
|
||||||
graphics.DrawPath(pen, path);
|
graphics.DrawPath(pen, path);
|
||||||
|
|
||||||
matrix.Dispose();
|
|
||||||
graphics.Dispose();
|
|
||||||
|
|
||||||
return img;
|
return img;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +317,8 @@ namespace OpenNest
|
|||||||
curpos = pt;
|
curpos = pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddProgram(GraphicsPath path, Program pgm, Mode mode, ref Vector curpos)
|
private static void AddProgram(GraphicsPath path, Program pgm, Mode mode, ref Vector curpos,
|
||||||
|
System.Func<LayerType, bool> includeLayer = null)
|
||||||
{
|
{
|
||||||
// Capture the frame origin at entry. Sub-program Offsets are relative
|
// Capture the frame origin at entry. Sub-program Offsets are relative
|
||||||
// to this fixed origin, not to the current tool position.
|
// to this fixed origin, not to the current tool position.
|
||||||
@@ -309,7 +345,9 @@ namespace OpenNest
|
|||||||
case CodeType.ArcMove:
|
case CodeType.ArcMove:
|
||||||
{
|
{
|
||||||
var arc = (ArcMove)code;
|
var arc = (ArcMove)code;
|
||||||
if (arc.Layer != LayerType.Leadin && arc.Layer != LayerType.Leadout)
|
if (includeLayer == null
|
||||||
|
? arc.Layer != LayerType.Leadin && arc.Layer != LayerType.Leadout
|
||||||
|
: !arc.Suppressed && includeLayer(arc.Layer))
|
||||||
{
|
{
|
||||||
if (currentFigure == null)
|
if (currentFigure == null)
|
||||||
currentFigure = new GraphicsPath();
|
currentFigure = new GraphicsPath();
|
||||||
@@ -329,7 +367,9 @@ namespace OpenNest
|
|||||||
case CodeType.LinearMove:
|
case CodeType.LinearMove:
|
||||||
{
|
{
|
||||||
var line = (LinearMove)code;
|
var line = (LinearMove)code;
|
||||||
if (line.Layer != LayerType.Leadin && line.Layer != LayerType.Leadout)
|
if (includeLayer == null
|
||||||
|
? line.Layer != LayerType.Leadin && line.Layer != LayerType.Leadout
|
||||||
|
: !line.Suppressed && includeLayer(line.Layer))
|
||||||
{
|
{
|
||||||
if (currentFigure == null)
|
if (currentFigure == null)
|
||||||
currentFigure = new GraphicsPath();
|
currentFigure = new GraphicsPath();
|
||||||
@@ -372,7 +412,7 @@ namespace OpenNest
|
|||||||
frameOrigin.X + subpgm.Offset.X,
|
frameOrigin.X + subpgm.Offset.X,
|
||||||
frameOrigin.Y + subpgm.Offset.Y
|
frameOrigin.Y + subpgm.Offset.Y
|
||||||
);
|
);
|
||||||
AddProgram(path, subpgm.Program, mode, ref curpos);
|
AddProgram(path, subpgm.Program, mode, ref curpos, includeLayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
mode = tmpmode;
|
mode = tmpmode;
|
||||||
|
|||||||
+74
-58
@@ -1,4 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Drawing.Drawing2D;
|
using System.Drawing.Drawing2D;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -18,8 +18,10 @@ namespace OpenNest
|
|||||||
private static Pen leadInPen;
|
private static Pen leadInPen;
|
||||||
|
|
||||||
private Color color;
|
private Color color;
|
||||||
|
private Color? colorOverride;
|
||||||
private Brush brush;
|
private Brush brush;
|
||||||
private Pen pen;
|
private Pen pen;
|
||||||
|
private ColorScheme colorScheme = ColorScheme.Default;
|
||||||
|
|
||||||
private List<PointF[]> _offsetPolygonPoints;
|
private List<PointF[]> _offsetPolygonPoints;
|
||||||
private double _cachedOffsetSpacing;
|
private double _cachedOffsetSpacing;
|
||||||
@@ -45,7 +47,7 @@ namespace OpenNest
|
|||||||
if (part.BaseDrawing.Color.IsEmpty)
|
if (part.BaseDrawing.Color.IsEmpty)
|
||||||
part.BaseDrawing.Color = Color.FromArgb(130, 204, 130);
|
part.BaseDrawing.Color = Color.FromArgb(130, 204, 130);
|
||||||
|
|
||||||
Color = part.BaseDrawing.Color;
|
SetDisplayColor(part.BaseDrawing.Color);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal bool IsDirty { get; set; }
|
internal bool IsDirty { get; set; }
|
||||||
@@ -56,37 +58,49 @@ namespace OpenNest
|
|||||||
|
|
||||||
public GraphicsPath LeadInPath { get; private set; }
|
public GraphicsPath LeadInPath { get; private set; }
|
||||||
|
|
||||||
|
public GraphicsPath EtchPath { get; private set; }
|
||||||
|
|
||||||
|
// Etch-only parts have no material path, but their visible closed mark must
|
||||||
|
// remain hit-testable without treating scribe geometry as a cut or fill.
|
||||||
|
internal GraphicsPath SelectionPath => Path?.PointCount > 0 ? Path : EtchPath;
|
||||||
|
|
||||||
|
internal RectangleF DisplayBounds { get; private set; }
|
||||||
|
|
||||||
public Color Color
|
public Color Color
|
||||||
{
|
{
|
||||||
get { return color; }
|
get { return color; }
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
color = value;
|
colorOverride = value;
|
||||||
|
SetDisplayColor(value);
|
||||||
if (brush != null)
|
|
||||||
brush.Dispose();
|
|
||||||
|
|
||||||
brush = new SolidBrush(value);
|
|
||||||
|
|
||||||
if (pen != null)
|
|
||||||
pen.Dispose();
|
|
||||||
|
|
||||||
pen = new Pen(ControlPaint.Dark(value));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SetDisplayColor(Color value)
|
||||||
|
{
|
||||||
|
color = value;
|
||||||
|
brush?.Dispose();
|
||||||
|
brush = new SolidBrush(value);
|
||||||
|
pen?.Dispose();
|
||||||
|
pen = new Pen(colorScheme.GetPartOutlineColor(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshColors()
|
||||||
|
{
|
||||||
|
var fill = colorOverride ?? BasePart.BaseDrawing.Color;
|
||||||
|
if (color != fill || pen.Color != colorScheme.GetPartOutlineColor(fill))
|
||||||
|
SetDisplayColor(fill);
|
||||||
|
}
|
||||||
|
|
||||||
public void Draw(Graphics g)
|
public void Draw(Graphics g)
|
||||||
{
|
{
|
||||||
if (IsSelected)
|
RefreshColors();
|
||||||
{
|
using var schemeSelectionBrush = IsSelected && !colorScheme.SelectedPartColor.IsEmpty
|
||||||
g.FillPath(selectedBrush, Path);
|
? new SolidBrush(colorScheme.SelectedPartColor) : null;
|
||||||
g.DrawPath(selectedPen, Path);
|
g.FillPath(schemeSelectionBrush ?? (IsSelected ? selectedBrush : brush), Path);
|
||||||
}
|
DrawEtch(g);
|
||||||
else
|
// Keep real cuts visible even where an etch overlaps them.
|
||||||
{
|
g.DrawPath(IsSelected && colorScheme.PartOutlineColor.IsEmpty ? selectedPen : pen, Path);
|
||||||
g.FillPath(brush, Path);
|
|
||||||
g.DrawPath(pen, Path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (LeadInPath != null)
|
if (LeadInPath != null)
|
||||||
g.DrawPath(leadInPen, LeadInPath);
|
g.DrawPath(leadInPen, LeadInPath);
|
||||||
@@ -94,19 +108,7 @@ namespace OpenNest
|
|||||||
|
|
||||||
public void Draw(Graphics g, string id)
|
public void Draw(Graphics g, string id)
|
||||||
{
|
{
|
||||||
if (IsSelected)
|
Draw(g);
|
||||||
{
|
|
||||||
g.FillPath(selectedBrush, Path);
|
|
||||||
g.DrawPath(selectedPen, Path);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
g.FillPath(brush, Path);
|
|
||||||
g.DrawPath(pen, Path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (LeadInPath != null)
|
|
||||||
g.DrawPath(leadInPen, LeadInPath);
|
|
||||||
|
|
||||||
using var sf = new StringFormat
|
using var sf = new StringFormat
|
||||||
{
|
{
|
||||||
@@ -125,10 +127,18 @@ namespace OpenNest
|
|||||||
|
|
||||||
public GraphicsPath OffsetPath { get; private set; }
|
public GraphicsPath OffsetPath { get; private set; }
|
||||||
|
|
||||||
|
internal void DrawEtch(Graphics g)
|
||||||
|
{
|
||||||
|
if (EtchPath == null || EtchPath.PointCount == 0)
|
||||||
|
return;
|
||||||
|
using var etchPen = new Pen(colorScheme.EtchColor, 1.5f);
|
||||||
|
g.DrawPath(etchPen, EtchPath);
|
||||||
|
}
|
||||||
|
|
||||||
private Vector ComputeLabelPoint()
|
private Vector ComputeLabelPoint()
|
||||||
{
|
{
|
||||||
var entities = ConvertProgram.ToGeometry(BasePart.BaseDrawing.Program);
|
var entities = ConvertProgram.ToGeometry(BasePart.BaseDrawing.Program);
|
||||||
var nonRapid = entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
var nonRapid = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList();
|
||||||
|
|
||||||
var shapes = ShapeBuilder.GetShapes(nonRapid);
|
var shapes = ShapeBuilder.GetShapes(nonRapid);
|
||||||
|
|
||||||
@@ -158,26 +168,31 @@ namespace OpenNest
|
|||||||
|
|
||||||
public void Update(DrawControl plateView)
|
public void Update(DrawControl plateView)
|
||||||
{
|
{
|
||||||
if (BasePart.HasManualLeadIns)
|
colorScheme = (plateView as PlateView)?.ColorScheme ?? ColorScheme.Default;
|
||||||
{
|
RefreshColors();
|
||||||
BasePart.Program.GetGraphicsPaths(
|
BasePart.Program.GetDisplayPaths(BasePart.Location,
|
||||||
BasePart.Location,
|
out var cutPath, out var leadPath, out var etchPath);
|
||||||
out var cutPath,
|
cutPath.Transform(plateView.Matrix);
|
||||||
out var leadPath
|
leadPath.Transform(plateView.Matrix);
|
||||||
);
|
etchPath.Transform(plateView.Matrix);
|
||||||
cutPath.Transform(plateView.Matrix);
|
Path?.Dispose();
|
||||||
leadPath.Transform(plateView.Matrix);
|
LeadInPath?.Dispose();
|
||||||
Path = cutPath;
|
EtchPath?.Dispose();
|
||||||
LeadInPath?.Dispose();
|
Path = cutPath;
|
||||||
LeadInPath = leadPath;
|
EtchPath = etchPath;
|
||||||
}
|
LeadInPath = BasePart.HasManualLeadIns ? leadPath : null;
|
||||||
else
|
if (!BasePart.HasManualLeadIns)
|
||||||
{
|
leadPath.Dispose();
|
||||||
Path = GraphicsHelper.GetGraphicsPath(BasePart.Program, BasePart.Location);
|
|
||||||
Path.Transform(plateView.Matrix);
|
var bounds = cutPath.GetBounds();
|
||||||
LeadInPath?.Dispose();
|
if (etchPath.PointCount > 0)
|
||||||
LeadInPath = null;
|
bounds = cutPath.PointCount > 0 ? RectangleF.Union(bounds, etchPath.GetBounds()) : etchPath.GetBounds();
|
||||||
}
|
if (LeadInPath?.PointCount > 0)
|
||||||
|
bounds = cutPath.PointCount > 0 || etchPath.PointCount > 0
|
||||||
|
? RectangleF.Union(bounds, LeadInPath.GetBounds()) : LeadInPath.GetBounds();
|
||||||
|
// Include screen-space stroke width, even for a horizontal or vertical mark.
|
||||||
|
bounds.Inflate(1, 1);
|
||||||
|
DisplayBounds = bounds;
|
||||||
|
|
||||||
// _labelPoint is computed from BaseDrawing.Program's current geometry, which already
|
// _labelPoint is computed from BaseDrawing.Program's current geometry, which already
|
||||||
// carries BaseDrawing.Program.Rotation (nonzero for canonical-frame drawings, e.g. in
|
// carries BaseDrawing.Program.Rotation (nonzero for canonical-frame drawings, e.g. in
|
||||||
@@ -371,7 +386,8 @@ namespace OpenNest
|
|||||||
|
|
||||||
public void Update()
|
public void Update()
|
||||||
{
|
{
|
||||||
Color = BasePart.BaseDrawing.Color;
|
colorOverride = null;
|
||||||
|
RefreshColors();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,14 @@ Invalid output is printed and rejected with exit code 2 without saving or postin
|
|||||||
|
|
||||||
MCP `autonest_plate` requires an empty target. The stdio server serializes all tool calls sharing its mutable session, so another request cannot change drawings or occupy a target during a solve. `allow_invalid` defaults to false. It reports violations and makes no changes on rejection, including with an override when the output is unrepresentable or contains multiple sheets. `engine` defaults to Default, and its description lists the built-in engines with what each suits. Fill tools (`fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`) remain separate and default to the Fill strategy. Console and MCP load jobs plug-ins from `Engines/` beside their executable.
|
MCP `autonest_plate` requires an empty target. The stdio server serializes all tool calls sharing its mutable session, so another request cannot change drawings or occupy a target during a solve. `allow_invalid` defaults to false. It reports violations and makes no changes on rejection, including with an override when the output is unrepresentable or contains multiple sheets. `engine` defaults to Default, and its description lists the built-in engines with what each suits. Fill tools (`fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`) remain separate and default to the Fill strategy. Console and MCP load jobs plug-ins from `Engines/` beside their executable.
|
||||||
|
|
||||||
|
MCP `pack_plate` and `autonest_plate` report each requested drawing's newly placed count and remaining quantity, indexed by request; partial fulfillment is explicitly warned about. Per-sheet ratios are not enforced or required: an uneven sheet is acceptable if the remaining quantities are placed elsewhere in the nest. These two plate tools operate on a single selected plate and do not automatically finish a multi-sheet job; callers can use `autonest_job` for that. Duplicate request tokens and ambiguous drawing names are refused before placement. A zero-quantity pack request places zero parts for that drawing; negative quantities are rejected.
|
||||||
|
|
||||||
|
`autonest_job(plateIndex, drawingNames, quantities, engine?, no_new_plates=false, sheets?)` is the whole-session completion path. Quantities are total targets across existing placed parts (physical plate quantity included) plus newly proposed sheets. Without `sheets`, the selected empty cutoff-free plate supplies stock size, spacing, quadrant, grain and cutting settings; other empty plates with equivalent persisted settings may be reused first, then additional matching plates are created as needed. `no_new_plates=true` limits this legacy solve to existing matching empty plates. The request uses the independently validated `NestPipeline` and commits only when every requested quantity is met; invalid, incomplete, cancelled or unknown-engine proposals leave existing plates and counts unchanged. A no-placement solver result is not proof that geometry cannot fit. `pack_plate` and `autonest_plate` remain single-sheet operations.
|
||||||
|
|
||||||
|
For finite mixed-size inventory, supply `sheets` as an array, for example `{"plateIndex":0,"drawingNames":"large,small","quantities":"1,1","engine":"Rectangles","sheets":[{"width":60,"length":120,"quantity":1},{"width":48,"length":96,"quantity":2}]}`. Width and length have the same units and orientation as `create_plate`. Every row's positive integer `quantity` is the total physical sheets available for this solve, including any compatible pre-created empty plates; it is never a `Plate.Quantity` repeat multiplier or additional stock. The selected plate supplies common spacing, edge spacing, quadrant, grain and cutting settings, but is reused only if its size matches a chosen sheet. Other compatible empty sheets can be reused; occupied or cutoff-bearing sheets cannot. Only needed different-sized sheets are created. Duplicate size pairs, nonpositive or nonfinite dimensions, unusable work areas, nonpositive quantities, an empty list and excessive total inventory are refused before solving. `sheets` cannot be combined with `no_new_plates=true`. Omit `sheets` to retain the previous single-size behavior. Successful output reports `stock-0`, `stock-1`, etc. in input order with used/remaining/reused/created physical counts, plus committed session plate index to stock ID and per-drawing totals. Stock is request-local, not a persistent purchasing catalog. Unused empty templates may be omitted by the `.nest` writer on save.
|
||||||
|
|
||||||
|
MCP `delete_plate(plateIndex)` removes only a sheet with no parts and no cutoff definitions, from either loaded or newly created session plates. Occupied, cutoff-bearing and invalid indices are refused without changing the session. After deletion every later index shifts down by one; inspect `get_plate_info` before further edits. This is session cleanup, not a data purge or a save-time option. `save_nest` lists all session plates in its response, but the existing `.nest` writer omits empty cutoff-free plates from the serialized file; reloading may therefore have fewer plates than the response says. There is no opt-in switch to preserve empty plates in the file.
|
||||||
|
|
||||||
### MCP engine development harness
|
### MCP engine development harness
|
||||||
|
|
||||||
`test_engine` builds and runs `OpenNest.Console` in a configured, trusted checkout.
|
`test_engine` builds and runs `OpenNest.Console` in a configured, trusted checkout.
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ For a 120-by-81-inch sheet whose parts extend through 80 inches, the nominal ske
|
|||||||
|
|
||||||
## Part sequencing
|
## Part sequencing
|
||||||
|
|
||||||
|
For **Plate > Sequence Parts > Manual Sequence**, choose the one-based sequence number and hover a part on the sheet; only the part under the pointer is outlined. Click it to place it at that exact position, then the number advances up to the last part. Clicking empty sheet space changes nothing. Use **Done**, the window close button, right-click on the sheet, or Escape to leave the tool. Manual reordering changes neither geometry nor quantities.
|
||||||
|
|
||||||
After adding cutoffs, apply **Part Sequencing** to the current plate or all plates. Each cutoff is moved earlier as needed so it is cut before every part its nominal line passes through. Ordinary parts retain their relative order from the chosen sequencing route; a tail separator that crosses no parts keeps its normal route position rather than being forced to the front. Cutoff geometry, clearance, part programs, placements, and quantities are unchanged.
|
After adding cutoffs, apply **Part Sequencing** to the current plate or all plates. Each cutoff is moved earlier as needed so it is cut before every part its nominal line passes through. Ordinary parts retain their relative order from the chosen sequencing route; a tail separator that crosses no parts keeps its normal route position rather than being forced to the front. Cutoff geometry, clearance, part programs, placements, and quantities are unchanged.
|
||||||
|
|
||||||
The dependency check uses the nominal horizontal/vertical line and its start/end limits, not the trimmed cutting segments, which intentionally skip the parts. It conservatively checks placed part bounds (including edge contacts), so a cutoff through a concave recess can also move ahead of that part. Definitions are matched by drawing identity, not their displayed names. A cutoff part with no matching definition is conservatively ordered before all ordinary parts.
|
The dependency check uses the nominal horizontal/vertical line and its start/end limits, not the trimmed cutting segments, which intentionally skip the parts. It conservatively checks placed part bounds (including edge contacts), so a cutoff through a concave recess can also move ahead of that part. Definitions are matched by drawing identity, not their displayed names. A cutoff part with no matching definition is conservatively ordered before all ordinary parts.
|
||||||
|
|||||||
+71
-18
@@ -146,11 +146,24 @@ other curve already touches, while a contact anywhere else on the line still ref
|
|||||||
|
|
||||||
Source parts rank by modeled travel (material-centre distance at a holed-part
|
Source parts rank by modeled travel (material-centre distance at a holed-part
|
||||||
boundary); within a part, preferred contour order and facing-entry rank precede
|
boundary); within a part, preferred contour order and facing-entry rank precede
|
||||||
travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names are not tie breakers. The expansion budget
|
travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names
|
||||||
counts rejected candidates and frontier ranking as well as accepted moves, before
|
are not tie breakers.
|
||||||
emission; it is not a wall-clock timeout. Callers can cancel. Exhaustion may occur
|
|
||||||
before already-generated siblings are traversed; it returns a refusal, not an
|
When the next whole part is fixed by the tour or a part is already active, the
|
||||||
unranked fallback or a proof of geometric impossibility.
|
search generates and checks contour alternatives on demand in contour/entry rank
|
||||||
|
order. It does not emit every sibling before trying the first. Backtracking still
|
||||||
|
visits the remaining alternatives when needed; none is pruned. The free whole-part
|
||||||
|
fallback still evaluates all ready sources before ranking their travel distances.
|
||||||
|
|
||||||
|
The expansion budget counts evaluated candidates and frontier ranking, including
|
||||||
|
rejections, before emission; it is not a wall-clock timeout. An unvisited sibling
|
||||||
|
consumes no additional DFS-prefix emission work or per-sibling expansion charge;
|
||||||
|
entry-catalogue prechecks still emit isolated contours and consume budget.
|
||||||
|
Consequently a bounded search can
|
||||||
|
reach a different frontier and report different rejected approaches than eager
|
||||||
|
expansion did. All visited candidates retain their lead/rapid checks, and a selected
|
||||||
|
plan still receives fresh independent replay. Callers can cancel. Exhaustion returns
|
||||||
|
a refusal, not an unranked fallback or proof of geometric impossibility.
|
||||||
|
|
||||||
## Automatic outside entries and look-ahead
|
## Automatic outside entries and look-ahead
|
||||||
|
|
||||||
@@ -219,7 +232,9 @@ against owned clean material. Neither mode certifies final NC, production cuttin
|
|||||||
readiness or physical machine safety.
|
readiness or physical machine safety.
|
||||||
|
|
||||||
Findings and source ordinals use the original zero-based source list, not proposed
|
Findings and source ordinals use the original zero-based source list, not proposed
|
||||||
sequence positions. A non-ready result contains no proposed order or unsafe fallback.
|
sequence positions. Strict service refusals contain no proposed order. The desktop batch may
|
||||||
|
produce a separate `BestEffort` proposal as described below; it never labels it `Ready` or
|
||||||
|
sets `IndependentlyReplayed`.
|
||||||
|
|
||||||
- `ConstraintConflict`: fixed programs or explored fixed routing violate the
|
- `ConstraintConflict`: fixed programs or explored fixed routing violate the
|
||||||
modeled constraints. Locked internal crossings cannot be repaired by regeneration.
|
modeled constraints. Locked internal crossings cannot be repaired by regeneration.
|
||||||
@@ -293,8 +308,8 @@ plans every plate that has parts. Both open one dialog built on
|
|||||||
- The dialog starts from the plate's cutting settings (or the last-used settings) and plans
|
- The dialog starts from the plate's cutting settings (or the last-used settings) and plans
|
||||||
at once. `Cutting Settings...` edits them and `Keep the current part order` fixes the
|
at once. `Cutting Settings...` edits them and `Keep the current part order` fixes the
|
||||||
whole-part order; either change replans. The settings are confirmed parameters: every
|
whole-part order; either change replans. The settings are confirmed parameters: every
|
||||||
unlocked part's lead-ins are regenerated, and locked parts keep programs that must
|
unlocked part's lead-ins are regenerated. Locked parts keep their exact programs;
|
||||||
already pass the checks.
|
the strict route checks them, while any best-effort warnings require explicit review below.
|
||||||
- A missing or zero-length lead-in is reported directly, rather than as a search-limit
|
- A missing or zero-length lead-in is reported directly, rather than as a search-limit
|
||||||
failure. Open `Cutting Settings...`, choose a lead-in other than `None` with a nonzero
|
failure. Open `Cutting Settings...`, choose a lead-in other than `None` with a nonzero
|
||||||
length on the affected `External`, `Internal`, or `Arc / Circle` tab, then replan.
|
length on the affected `External`, `Internal`, or `Arc / Circle` tab, then replan.
|
||||||
@@ -302,23 +317,36 @@ plans every plate that has parts. Both open one dialog built on
|
|||||||
When a lead hits another part, the finding suggests more spacing or a shorter lead;
|
When a lead hits another part, the finding suggests more spacing or a shorter lead;
|
||||||
when no tested entry fits, it suggests reducing lead-in length and, if neighbours
|
when no tested entry fits, it suggests reducing lead-in length and, if neighbours
|
||||||
obstruct it, spacing the parts farther apart. These are suggestions, not guaranteed
|
obstruct it, spacing the parts farther apart. These are suggestions, not guaranteed
|
||||||
fixes: replanning runs the same checks, and Apply stays blocked until every plate is ready.
|
fixes: replanning runs the same strict checks; an unverified fallback requires
|
||||||
|
separate warning acceptance and is not approval to post or cut.
|
||||||
|
- The desktop free-order batch first tries up to eight entries per contour for at most
|
||||||
|
1,000 expansions. Only a Ready proposal that passes independent replay is used;
|
||||||
|
otherwise it runs the original 16-entry search with its entire plate budget.
|
||||||
|
Cancellation stops both attempts. This short pass can choose a different valid
|
||||||
|
route, and a hard plate can take up to 1,000 extra expansions before the full
|
||||||
|
attempt. The current-order retry still uses its original 16-entry budget.
|
||||||
|
Returned expansion counts describe the retained attempt, not a discarded
|
||||||
|
short pass plus its full-cap retry.
|
||||||
- Every plate is captured on the UI thread and checked and planned on a worker. Clean part
|
- Every plate is captured on the UI thread and checked and planned on a worker. Clean part
|
||||||
material is checked for overlaps with the pre-post overlap analyzer; overlapping parts or
|
material is checked for overlaps with the pre-post overlap analyzer; known overlapping parts
|
||||||
an incomplete check (see [pre-post verification](post-verification.md)) block that plate
|
still block that plate whatever its route. Incomplete checks remain visible warnings, never
|
||||||
whatever its route. A free-order search that ends
|
a clear result (see [pre-post verification](post-verification.md)). A free-order search that ends
|
||||||
`NoSolutionWithinBudget` is retried once with the current part order, and the summary
|
`NoSolutionWithinBudget` is retried once with the current part order, and the summary
|
||||||
says the order was kept. Both are allowed 400 expansions per part (at least the
|
says the order was kept. Both are allowed 400 expansions per part (at least the
|
||||||
default 20000), because both still plan contour order and entries for every part.
|
default 20000), because both still plan contour order and entries for every part.
|
||||||
- The summary lists every plate: ready plates with part counts and rapid travel, others
|
- The summary lists every plate: ready plates with part counts and rapid travel, others
|
||||||
with their status and findings. Finding part numbers are the plate's current order, as the
|
with their status and findings. Unverified proposals show every overlap and route
|
||||||
|
warning in the scrollable summary before the acceptance checkbox is used; they do
|
||||||
|
not truncate later parts' warnings. Finding part numbers are the plate's current order, as the
|
||||||
editor numbers them. The preview shows the active plate detached from the nest (quantity
|
editor numbers them. The preview shows the active plate detached from the nest (quantity
|
||||||
zero, so drawing quantities do not change) in the proposed order with the proposed
|
zero, so drawing quantities do not change) in the proposed order with the proposed
|
||||||
programs. It is shown only for a ready plate that still matches what was planned: a
|
programs. Ready and best-effort proposals can be previewed only while the plate still
|
||||||
refused plate may hold program graphs that are unsafe to copy, and a changed one would
|
matches capture. Best-effort previews are labelled `UNVERIFIED`. Refused program graphs
|
||||||
draw replayed programs at poses that were never checked.
|
are never copied, and changed plates require replanning.
|
||||||
- Apply is enabled only when every plate is ready, and it is all or nothing through
|
- Apply requires usable output for every plate and remains all or nothing. A ready batch
|
||||||
`CuttingPlanService.Apply`. After it applies, each plate keeps its own copy of the
|
can apply immediately; an unverified batch requires the unchecked, per-proposal
|
||||||
|
`I reviewed the warnings. Apply this unverified plan.` checkbox. Replanning clears it.
|
||||||
|
Both use the same owned-program, freshness and rollback boundary. After it applies, each plate keeps its own copy of the
|
||||||
confirmed settings, which also become the saved defaults. `Stale` keeps the dialog open
|
confirmed settings, which also become the saved defaults. `Stale` keeps the dialog open
|
||||||
and asks for a replan; nothing changes.
|
and asks for a replan; nothing changes.
|
||||||
- Closing or cancelling while planning cancels the worker and keeps the dialog open until
|
- Closing or cancelling while planning cancels the worker and keeps the dialog open until
|
||||||
@@ -332,6 +360,31 @@ plans every plate that has parts. Both open one dialog built on
|
|||||||
therefore follow moves and clones just like incremental (G91) programs; displaying a
|
therefore follow moves and clones just like incremental (G91) programs; displaying a
|
||||||
part does not rewrite its program or coordinate mode.
|
part does not rewrite its program or coordinate mode.
|
||||||
|
|
||||||
|
### Best-effort fallback for imperfect geometry
|
||||||
|
|
||||||
|
When strict planning reports unsupported or incomplete geometry, the desktop batch
|
||||||
|
automatically attempts a bounded, deterministic best-effort proposal. Touching, intersecting
|
||||||
|
or numerically uncertain material boundaries need not prevent lead-in generation when their
|
||||||
|
closed executable contours are readable. The fallback reuses the existing contour emitter:
|
||||||
|
internal contours first, the largest bounding perimeter last, nearest entry from the preceding
|
||||||
|
departure, with the confirmed lead styles. It retains source part order except to satisfy
|
||||||
|
proven cutoff and nested-part prerequisites. `Keep the current part order` still refuses an
|
||||||
|
order that contradicts a proven prerequisite. Uncertain containment is named as a warning.
|
||||||
|
|
||||||
|
No source contours are repaired, removed, simplified, or silently closed. Locked programs
|
||||||
|
and cutoffs remain exact. Unknown/null/recursive instruction graphs, suppressed or nonfinite
|
||||||
|
motions and contours the emitter cannot represent still refuse. This fallback is for
|
||||||
|
incomplete geometry checks, not every constraint conflict or exhausted search. Known
|
||||||
|
inter-part material overlap still blocks the entire batch.
|
||||||
|
|
||||||
|
The result retains strict refusal findings and available rapid-check findings, and is
|
||||||
|
explicitly not a certificate of lead clearance, rapid travel or material containment.
|
||||||
|
Inspect the preview and warnings before accepting it. A route with only an incomplete
|
||||||
|
overlap check may likewise be accepted with warnings. `CanApply` and parameterless batch
|
||||||
|
`Apply()` remain strict; `CanApplyWithWarnings` and `Apply(acceptWarnings: true)` are the
|
||||||
|
explicit review path. `CuttingPlanService.Apply` still rejects best-effort results directly.
|
||||||
|
Acceptance never skips the separate pre-post checks or grants posting consent.
|
||||||
|
|
||||||
## Remaining integration boundaries
|
## Remaining integration boundaries
|
||||||
|
|
||||||
The service does not establish clean-material non-overlap, scrap release by open
|
The service does not establish clean-material non-overlap, scrap release by open
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ Open/incomplete chains and ambiguous contacts conservatively block. This is not
|
|||||||
- CPU best-fit batches prepare contact topology once and use all vertices plus curve/line interior and curve/curve tangency events. The old leading-half vertex filter cannot establish the next blocker after a skipped touch.
|
- CPU best-fit batches prepare contact topology once and use all vertices plus curve/line interior and curve/curve tangency events. The old leading-half vertex filter cannot establish the next blocker after a skipped touch.
|
||||||
- GPU kernels retain nearest-hit reduction and return unsnapped contact witnesses. The shared CPU classifier accepts a blocking witness or replays the full query after a nonblocking witness, preserving tied/later blockers. Both batch APIs honor active buffer lengths and refresh mutated/reused segment arrays. The GPU distance adapter sends only exact cardinal directions to the axis-only slide interface; arbitrary directions and native curves use the shared CPU path.
|
- GPU kernels retain nearest-hit reduction and return unsnapped contact witnesses. The shared CPU classifier accepts a blocking witness or replays the full query after a nonblocking witness, preserving tied/later blockers. Both batch APIs honor active buffer lengths and refresh mutated/reused segment arrays. The GPU distance adapter sends only exact cardinal directions to the axis-only slide interface; arbitrary directions and native curves use the shared CPU path.
|
||||||
|
|
||||||
## Regression coverage
|
- Shift-click while cloning parts settles the copied group toward the plate quadrant. If its starting bounds overlap any placed part's bounds, it skips the coarse bounding-box pass and uses geometry directly; otherwise it tries coarse horizontal/vertical and vertical/horizontal orders. Each candidate then alternates geometry pushes until movement is negligible (at most 20 iterations), and the group nearest the quadrant's work-area corner wins. The coarse pass remains useful for avoiding sawtooth/rung traps when the starting boxes do not overlap.
|
||||||
|
|
||||||
|
## Regression coverage
|
||||||
`SlideContactTests` exercises cardinal line, translated line, reused edge-array, arbitrary-vector, native-entity, and both CPU batch paths. Cases include winding reversal, nonzero origins, rotated hooks, holes, separating circles, positive-distance grazing followed by a blocker, full-circle arc seams, concave/straight junctions, thin rings, and circle/line interior contact.
|
`SlideContactTests` exercises cardinal line, translated line, reused edge-array, arbitrary-vector, native-entity, and both CPU batch paths. Cases include winding reversal, nonzero origins, rotated hooks, holes, separating circles, positive-distance grazing followed by a blocker, full-circle arc seams, concave/straight junctions, thin rings, and circle/line interior contact.
|
||||||
|
|
||||||
`CompactorTests` covers the reported sequence (push left with spacing, then right/up/down), genuine zero-distance blocking, zero/nonzero-spacing later hooks, and inside-hole pushes through both direct and plate entry points. Physical spacing is measured from raw outlines rather than the inflated contours used by the solver.
|
`CompactorTests` covers the reported sequence (push left with spacing, then right/up/down), genuine zero-distance blocking, zero/nonzero-spacing later hooks, and inside-hole pushes through both direct and plate entry points. Physical spacing is measured from raw outlines rather than the inflated contours used by the solver.
|
||||||
|
|||||||
@@ -131,8 +131,12 @@ dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter FullyQualifiedName~Rep
|
|||||||
```
|
```
|
||||||
|
|
||||||
The writer tests read page sizes and content streams with PDFsharp. Text
|
The writer tests read page sizes and content streams with PDFsharp. Text
|
||||||
assertions extract with Poppler's `pdftotext -layout` and are skipped, not
|
assertions use Poppler's `pdftotext -bbox` coordinates to rebuild visual rows;
|
||||||
passed, when poppler-utils is absent. For manual inspection also use `pdfinfo`,
|
`-layout` can return columns in a different order on Windows. They are skipped,
|
||||||
|
not passed, when poppler-utils is absent. The Windows release workflow installs
|
||||||
|
an SHA-256-pinned Poppler binary because the runner's built-in `pdftotext` lacks
|
||||||
|
`-bbox`; no PDF text test is skipped for that reason in a release candidate.
|
||||||
|
For manual inspection also use `pdfinfo`,
|
||||||
`pdffonts` and `pdftoppm -png` (Linux verification baseline: poppler-utils
|
`pdffonts` and `pdftoppm -png` (Linux verification baseline: poppler-utils
|
||||||
24.02.0). Confirm page sizes, quantity rows, page X of Y, embedded fonts, native
|
24.02.0). Confirm page sizes, quantity rows, page X of Y, embedded fonts, native
|
||||||
vector curves, unfilled holes and visible tab gaps with no connecting stroke.
|
vector curves, unfilled holes and visible tab gaps with no connecting stroke.
|
||||||
|
|||||||
@@ -241,6 +241,10 @@ curl --fail http://<bind-address>:<port>/healthz # {"status":"ok"}
|
|||||||
|
|
||||||
In the desktop app choose **File > Storage Mode...**, Database, and enter the base
|
In the desktop app choose **File > Storage Mode...**, Database, and enter the base
|
||||||
URL `http://<bind-address>:<port>`, without `/healthz` or `/api/nests`.
|
URL `http://<bind-address>:<port>`, without `/healthz` or `/api/nests`.
|
||||||
|
To set or change just the saved server address without changing File/Database mode,
|
||||||
|
use **Tools > Options > Nest server URL > Set / Change Server**. This saves the URL
|
||||||
|
immediately, independently of the Options Save button; it does not migrate nests
|
||||||
|
or enable Database mode. Canceling Options afterward does not undo that address.
|
||||||
|
|
||||||
### Backup, restore, and upgrade
|
### Backup, restore, and upgrade
|
||||||
|
|
||||||
|
|||||||
+37
-2
@@ -70,8 +70,9 @@ remain separate sequencer/verification work.
|
|||||||
## Filling cutouts (not yet in production)
|
## Filling cutouts (not yet in production)
|
||||||
|
|
||||||
Placing parts inside another part's enclosed cutout is being built as a step that runs before any
|
Placing parts inside another part's enclosed cutout is being built as a step that runs before any
|
||||||
engine, so every engine benefits. Nothing calls it yet: a part inside a cutout must be cut before
|
engine, so every engine benefits. Only an internal test-only pipeline preview calls it: a part
|
||||||
the cutout's contour, and the sequencer does not enforce that order.
|
inside a cutout must be cut before the cutout's contour, and the cutting/post safety gates are
|
||||||
|
not complete. Ordinary whole-job calls do not enable this path.
|
||||||
|
|
||||||
`CutoutLatticeFill` (`OpenNest.Engine/Jobs/Cutouts/`) fills one closed cutout with copies of one
|
`CutoutLatticeFill` (`OpenNest.Engine/Jobs/Cutouts/`) fills one closed cutout with copies of one
|
||||||
part. It runs Fill over the cutout's bounds plus one part step on every side, then shifts
|
part. It runs Fill over the cutout's bounds plus one part step on every side, then shifts
|
||||||
@@ -83,6 +84,40 @@ layout check uses. The method suits many small copies in a large cutout; a few l
|
|||||||
meant for no-fit-polygon placement. Fill can return different, equally scored lattices on repeated
|
meant for no-fit-polygon placement. Fill can return different, equally scored lattices on repeated
|
||||||
calls for some parts, so results are not yet guaranteed identical between runs.
|
calls for some parts, so results are not yet guaranteed identical between runs.
|
||||||
|
|
||||||
|
The internal `CutoutRouter` can propose copies from the shifted lattice, then search bounded
|
||||||
|
inner-fit/NFP sample points for remaining copies and other insert requirements. It keeps
|
||||||
|
original requirement IDs, reindexes accepted copies and checks clearance against the frame
|
||||||
|
and every previously accepted insert. Lattice shifts account for occupied poses before
|
||||||
|
quantity trimming; a pre-fill work limit declines giant grids and lets bounded NFP sampling
|
||||||
|
try instead. A null NFP proposal is not proof of geometric impossibility. No material-area
|
||||||
|
ratio cutoff rejects a possible placement: the measured 0.10 and 0.35 ratios guide
|
||||||
|
search order only. Below 0.10 it starts with Fill for three or more copies; between
|
||||||
|
0.10 and 0.35 it compares Fill-plus-NFP with NFP-only counts; above 0.35 it tries
|
||||||
|
NFP first, then Fill if demand remains, taking the higher-count valid proposal.
|
||||||
|
An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole.
|
||||||
|
Six geometry-only, anonymized real-job probes and neutral ring fixtures informed
|
||||||
|
the search-order hints; these limited cases are not a global density guarantee.
|
||||||
|
The internal `NestPipeline.RunCutoutPreview` builds quantity-one proxy instances for eligible
|
||||||
|
closed-cutout frames. It considers legal frame rotations without outer-perimeter symmetry
|
||||||
|
reduction, shifts insert rotation policies into the local frame, and reserves certified router
|
||||||
|
poses by original requirement ID. It can fill multiple holes and repeated/different frame
|
||||||
|
requirements; inserts with a different priority or their own cutouts stay independent, so
|
||||||
|
bundles remain flat. It uses the largest offered spacing for every bundle because the current
|
||||||
|
job contract cannot condition insert demand on the stock selected later. Remaining demand is
|
||||||
|
offered to the selected engine once. Only actually placed proxies expand to original frame and
|
||||||
|
insert identities. An unplaced proxy's reservations return to the original unplaced demand;
|
||||||
|
they cannot be packed retroactively in that same solve. Engine indices and metadata are checked,
|
||||||
|
then every physical pose, stock, rotation, quantity and fulfillment is independently validated
|
||||||
|
before binding caller drawings. Incomplete, forged or geometrically invalid composites return
|
||||||
|
no bindable plates even with invalid-result consent. Candidate evaluation progress is forwarded
|
||||||
|
with unverified proxy commitment counts zeroed; transformed proxy commits are hidden. Physical
|
||||||
|
commits are reported only after physical validation. Preparation is bounded to 32 bundles
|
||||||
|
and 16 base sweep-angle samples per frame (automatic quarter-turns are all retained);
|
||||||
|
remaining copies stay ordinary requirements. Fixed legal proxy orientations are a
|
||||||
|
conservative search choice, not full engine choice among all legal orientations; unsupported
|
||||||
|
bundles remain ordinary independent requirements. This is an internal pipeline path, not a
|
||||||
|
production setting or cut-ready nest. Cutting/post safety remains pending before enablement.
|
||||||
|
|
||||||
## Renamed engines
|
## Renamed engines
|
||||||
|
|
||||||
The registry maps names used by earlier releases (the first two shipped as plug-ins) so saved
|
The registry maps names used by earlier releases (the first two shipped as plug-ins) so saved
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Nesting-view colors
|
||||||
|
|
||||||
|
Choose **Tools > Options > Color scheme > Workshop**, then **Save** for a
|
||||||
|
high-contrast per-drawing view:
|
||||||
|
|
||||||
|
- Generated fills: hue advances by 137.508 degrees per drawing (skipping the
|
||||||
|
green band reserved for etch) and cycles through eight saturation/lightness
|
||||||
|
tiers on an irrational stride, so parts sharing a sheet stay far apart in
|
||||||
|
color. All copies of a drawing keep its color.
|
||||||
|
- Thin dark gray `#303030` cut outlines.
|
||||||
|
- Bright green `#00FF00` etch strokes, independent of the part fill, so marks
|
||||||
|
that are not cuts cannot be mistaken for part outlines.
|
||||||
|
- Pale gray `#F2F2F2` sheet and neutral gray background.
|
||||||
|
|
||||||
|
The generator continues beyond the twelve preview samples instead of wrapping
|
||||||
|
through a short palette. It maximizes separation between consecutive drawing
|
||||||
|
indices, not spatial neighbors; very large jobs can still place similar colors
|
||||||
|
side by side. This is not a colorblind-safe or perceptually uniform palette. Selected parts retain the
|
||||||
|
standard translucent blue highlight; etch and cut colors stay unchanged.
|
||||||
|
Classic, Pastel and Dark remain available; saved scheme choices are not changed.
|
||||||
|
Applying a scheme recolors drawings in currently open nests. Drawings may retain
|
||||||
|
custom or saved fill colors; open a saved nest before applying Workshop to recolor it.
|
||||||
|
|
||||||
|
In plate views and drawing thumbnails, scribe/etch motions have a separate stroke
|
||||||
|
from material cuts. A closed etched circle or lettering does not make a hole in
|
||||||
|
the displayed part fill. An etch-only part remains selectable by its closed
|
||||||
|
mark in the plate view; etch lines still do not become cut material. Selection
|
||||||
|
changes the part highlight, not the etch color.
|
||||||
|
Lead-in/out strokes keep their existing orange-red appearance. Preview placements
|
||||||
|
show etches too. A cut coincident with an etch is drawn over the etch, so it is not
|
||||||
|
hidden by the mark.
|
||||||
|
|
||||||
|
This is a display aid, not a geometry classifier or machining-safety certificate.
|
||||||
|
Only motions classified as `Scribe` receive etch styling; a line incorrectly
|
||||||
|
classified as `Cut` remains a cut line. Drawing geometry, layer classifications,
|
||||||
|
cutting plans and post-processor output are not changed by the renderer. The CAD
|
||||||
|
conversion/program-editing views retain their own source/classification colors.
|
||||||
|
|
||||||
|
## Custom scheme files
|
||||||
|
|
||||||
|
Scheme JSON files in `Schemes/` beside the application may specify `etchColor`
|
||||||
|
and `partOutlineColor` as `#RRGGBB`. Optional `selectedPartColor` specifies an opaque
|
||||||
|
selection fill; missing or null retains the legacy translucent selection highlight.
|
||||||
|
Missing `etchColor` uses the bright green etch default.
|
||||||
|
Missing or null `partOutlineColor` retains the legacy darker-than-fill outline.
|
||||||
|
Optional `useGoldenAngleColors: true` selects generated drawing colors; missing
|
||||||
|
or false keeps the explicit `partColors` palette and its legacy cycling behavior.
|
||||||
|
Workshop tests check the first 1,000 generated fills stay far in RGB distance
|
||||||
|
from both the dark cut outline and the bright green etch stroke, and that no
|
||||||
|
fill hue falls in the etch green band. Selected rendering has separate bitmap tests.
|
||||||
|
Custom or previously saved fills are not automatically contrast-corrected;
|
||||||
|
apply Workshop after loading the nest to recolor its drawings. Existing scheme
|
||||||
|
names, saved choices, custom palettes and geometry remain unchanged.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
On Windows, run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj -c Release --filter FullyQualifiedName~EtchDisplayTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inspect an actual nest with both cuts and etches: select/deselect and move a
|
||||||
|
part, zoom in/out, inspect drawing thumbnails, and switch away from Workshop and
|
||||||
|
back. Confirm the marks remain readable, closed marks stay filled and real holes
|
||||||
|
remain holes. Automated bitmap tests verify the rendering rules, not an operator's
|
||||||
|
assessment of readability on a particular display.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Cutting-planner native curve reuse
|
||||||
|
|
||||||
|
The opt-in 144-part synthetic grid probe measures only `CuttingPlanService.Plan`, not build,
|
||||||
|
fixture creation, assertions, desktop rendering or posting:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
OPENNEST_RUN_CUTTING_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \
|
||||||
|
--filter 'FullyQualifiedName~ReorderSearchTests.DenseGrid144_PerformanceProbe' \
|
||||||
|
--logger 'console;verbosity=detailed'
|
||||||
|
```
|
||||||
|
|
||||||
|
Run separate fresh processes on the same machine with build and filesystem caches warm;
|
||||||
|
there is no in-process plan/JIT warmup, so these are first-call measurements. Compare
|
||||||
|
`Cutting144 elapsedMs`, `expansions`, `status` and `executionSha256`. The hash covers
|
||||||
|
expanded owned motions (including called hole programs), their layers, rapid flags,
|
||||||
|
exact coordinate bits, curve type and arc/circle parameters in source order; it does
|
||||||
|
not claim byte-identical authored G-code flags or post output. The test also requires
|
||||||
|
a Ready, independently replayed result and exact source-part conservation. Without
|
||||||
|
the environment flag it skips. Timing is diagnostic, not a deadline assertion.
|
||||||
|
|
||||||
|
The native curve wrapper now lazily owns one private native `Entity` for repeated
|
||||||
|
contact/containment queries. `ToEntity()` still makes a fresh object for other callers.
|
||||||
|
No lead or rapid check is skipped: long-lead/tiny-circle numerical contacts make a
|
||||||
|
fixed-extent material prefilter unsafe. Retest the cutting-planner and native-contact
|
||||||
|
regressions, especially near tangencies, repeated/concurrent queries and incomplete
|
||||||
|
material; do not infer posted NC or machine acceptance from this probe.
|
||||||
+8
-3
@@ -15,7 +15,9 @@ pulls from GitHub every hour; it refuses pushes.
|
|||||||
runner. It builds the solution, runs all six test projects in Release and
|
runner. It builds the solution, runs all six test projects in Release and
|
||||||
the main test project in Debug, then packages and smoke-tests the desktop app.
|
the main test project in Debug, then packages and smoke-tests the desktop app.
|
||||||
A test that is silent for five minutes is killed and named in the log, with a
|
A test that is silent for five minutes is killed and named in the log, with a
|
||||||
mini dump in the uploaded `windows-test-results` artifact.
|
mini dump in the uploaded `windows-test-results` artifact. The release job
|
||||||
|
installs a SHA-256-pinned Poppler Windows binary for PDF text/column tests;
|
||||||
|
the runner's older `pdftotext` lacks the required `-bbox` option.
|
||||||
Optional local/proprietary fixture and opt-in measurement tests may skip;
|
Optional local/proprietary fixture and opt-in measurement tests may skip;
|
||||||
inspect the uploaded TRX files rather than treating skips as passes.
|
inspect the uploaded TRX files rather than treating skips as passes.
|
||||||
4. Download the `OpenNest-X.Y.Z-win-x64` artifact and verify its `.sha256`.
|
4. Download the `OpenNest-X.Y.Z-win-x64` artifact and verify its `.sha256`.
|
||||||
@@ -68,9 +70,12 @@ or attestations (`--provenance=false --sbom=false`), and runs the real-client
|
|||||||
container persistence/backup/restore smoke. A Windows `v*` tag build alone never
|
container persistence/backup/restore smoke. A Windows `v*` tag build alone never
|
||||||
publishes a server image.
|
publishes a server image.
|
||||||
|
|
||||||
Publication requires owner-approved release intent: a **published GitHub Release**
|
Publication requires owner-approved release intent: a **published, non-prerelease GitHub Release**
|
||||||
or an explicit `Server image` dispatch **from `master`**, naming an existing strict
|
or an explicit `Server image` dispatch **from `master`**, naming an existing strict
|
||||||
`vX.Y.Z` tag (no prerelease, leading zero, or extra suffix). The tag must resolve to
|
`vX.Y.Z` tag (no prerelease suffix, leading zero, or extra suffix). GitHub pre-releases
|
||||||
|
publish only the Windows assets; they skip the server image publish and verification
|
||||||
|
jobs. Changing a pre-release to a full release later is a separate publication
|
||||||
|
decision. The tag must resolve to
|
||||||
a full commit already on `master`. For a published Release, that peeled commit
|
a full commit already on `master`. For a published Release, that peeled commit
|
||||||
must also equal the event's full `GITHUB_SHA`; a retargeted tag is refused. Manual
|
must also equal the event's full `GITHUB_SHA`; a retargeted tag is refused. Manual
|
||||||
dispatch intentionally resolves the approved existing tag, not the workflow's
|
dispatch intentionally resolves the approved existing tag, not the workflow's
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Automated Windows acceptance
|
||||||
|
|
||||||
|
The `windows-desktop` job in `.github/workflows/ci.yml` runs on GitHub-hosted
|
||||||
|
`windows-2022` for every pull request and `master` push. It executes the full
|
||||||
|
WinForms and FrontEnd test projects in Release, including shown-form STA tests
|
||||||
|
and Windows child-process lifetime tests. No production service, customer
|
||||||
|
fixture, physical GPU, or CNC controller is required.
|
||||||
|
|
||||||
|
Each test host has a five-minute hang watchdog with a mini dump; the job has a
|
||||||
|
20-minute limit. A failure in either suite fails the step, without suppressing
|
||||||
|
the other suite. This job is separate from the existing Linux `tests` aggregate;
|
||||||
|
require `windows-desktop` as well when assessing acceptance.
|
||||||
|
|
||||||
|
## Results and coverage contract
|
||||||
|
|
||||||
|
Download the `windows-desktop-test-results` artifact from the workflow run.
|
||||||
|
It contains both TRX files, any hang diagnostics, and:
|
||||||
|
|
||||||
|
- `windows-acceptance.json`: commit/tree, runner platform, workflow identifiers,
|
||||||
|
TRX hashes, enumerated results, failures/skips and per-area test evidence.
|
||||||
|
- `windows-acceptance.md`: a readable summary, also shown in the Actions job
|
||||||
|
summary, with remaining acceptance beside the automated result.
|
||||||
|
|
||||||
|
Artifacts are retained for 14 days. Save evidence needed for longer-lived task
|
||||||
|
verification before expiration. On pull requests the checked-out commit can be
|
||||||
|
GitHub's test merge commit; use the report's commit/tree and workflow link, not
|
||||||
|
just the branch name.
|
||||||
|
|
||||||
|
`scripts/windows-acceptance.json` is the committed coverage contract. It names
|
||||||
|
required test methods and the minimum number of executed cases for each
|
||||||
|
(including theory rows). The report fails if either TRX is absent/malformed,
|
||||||
|
empty or inconsistent; if a required method/case is missing, failed or skipped;
|
||||||
|
if any other test fails; or if a run was aborted. Skips outside the named contract
|
||||||
|
are retained explicitly, never counted as passes. Added cases are allowed;
|
||||||
|
removing or renaming required coverage requires an intentional manifest change.
|
||||||
|
The whole suites still run, not just this manifest's subset.
|
||||||
|
|
||||||
|
The areas are BOM import, Nest Info material preservation, saved-nest browsing,
|
||||||
|
nesting/commit policy, PDF export, the cutting dialog, automatic cutoffs,
|
||||||
|
overlap/posting consent, UI lifetime, import/export failures, and MCP processes.
|
||||||
|
The manifest is public test coverage only. Private tracker IDs, plans, customer
|
||||||
|
data, and acceptance notes stay out of the repository and CI artifacts.
|
||||||
|
|
||||||
|
The report is evidence, not automatic task closure. A passing area does not
|
||||||
|
establish absent implementation, real-service/two-PC behavior, DPI or physical
|
||||||
|
printing quality, packaged-build behavior, or machine safety. In particular,
|
||||||
|
fake-repository browser tests do not verify the HTTP/SQLite save/paging lifecycle.
|
||||||
|
Keep those remaining gates in the tracker; link exact test names and the workflow
|
||||||
|
run when updating an individual task.
|
||||||
|
|
||||||
|
## Repeat locally on Windows
|
||||||
|
|
||||||
|
Use a clean, committed checkout, .NET 8 and Python 3. Run from the repository root
|
||||||
|
in PowerShell in a logged-in Windows desktop session: an OpenSSH/service session
|
||||||
|
(session 0) cannot show the real native completion dialogs these tests exercise.
|
||||||
|
A remote interactive-session launch should use an explicitly approved temporary
|
||||||
|
scheduled task, without saved passwords, and remove that task after completion.
|
||||||
|
Test windows may appear briefly; do not manually acknowledge them during a run.
|
||||||
|
Always use a new results directory so stale TRX files cannot stand in for a failed
|
||||||
|
build. Do not reuse downloaded CI results as a new local run.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$results = Join-Path 'TestResults' ([guid]::NewGuid().ToString())
|
||||||
|
$failed = @()
|
||||||
|
foreach ($project in @('OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||||
|
dotnet test "$project/$project.csproj" -c Release --blame-hang-timeout 5m --blame-hang-dump-type mini --logger "trx;LogFileName=$project.trx" --results-directory $results
|
||||||
|
if ($LASTEXITCODE -ne 0) { $failed += $project }
|
||||||
|
}
|
||||||
|
python scripts/windows_acceptance.py --results $results
|
||||||
|
if ($LASTEXITCODE -ne 0 -or $failed.Count -gt 0) { throw 'Windows acceptance failed; inspect results.' }
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI refuses Linux/macOS as Windows runtime evidence and refuses tracked
|
||||||
|
source edits. The source revision is provenance of the checkout running the
|
||||||
|
command, not a cryptographic attestation of arbitrary supplied TRX files; the
|
||||||
|
fresh-checkout CI run and the fresh-directory local procedure establish that
|
||||||
|
relationship. The JSON retains hashes so the saved files can be matched later.
|
||||||
|
|
||||||
|
## Maintain the gate
|
||||||
|
|
||||||
|
Run `python scripts/test_windows_acceptance.py -v` on any platform after changing
|
||||||
|
the parser or manifest. Its generated TRX fixtures test the reporter only, not
|
||||||
|
the application. Exercise a draft PR on Windows before integrating new form
|
||||||
|
cases. Inspect actual TRX failures and skip reasons; a Linux cross-build is not
|
||||||
|
a Windows pass. Keep visual/operator limitations alongside the named cases.
|
||||||
@@ -698,6 +698,13 @@ class ImmutableTagCollisions(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class WorkflowBehavior(unittest.TestCase):
|
class WorkflowBehavior(unittest.TestCase):
|
||||||
|
def test_prerelease_skips_ghcr_publish_but_stable_release_and_manual_dispatch_remain(self):
|
||||||
|
workflow = pathlib.Path(__file__).resolve().parents[1] / ".github/workflows/server-image.yml"
|
||||||
|
publish = workflow.read_text().split("\n publish:\n", 1)[1].split("\n verify-published:\n", 1)[0]
|
||||||
|
condition = publish.split(" if: >-\n", 1)[1].split(" runs-on:", 1)[0]
|
||||||
|
self.assertIn("github.event_name == 'release' && github.event.release.prerelease == false", condition)
|
||||||
|
self.assertIn("github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master'", condition)
|
||||||
|
|
||||||
def test_build_log_pipeline_preserves_docker_failure(self):
|
def test_build_log_pipeline_preserves_docker_failure(self):
|
||||||
workflow = pathlib.Path(__file__).resolve().parents[1] / ".github/workflows/server-image.yml"
|
workflow = pathlib.Path(__file__).resolve().parents[1] / ".github/workflows/server-image.yml"
|
||||||
lines = workflow.read_text().splitlines()
|
lines = workflow.read_text().splitlines()
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Synthetic parser controls, not Windows application acceptance evidence."""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import windows_acceptance as acceptance
|
||||||
|
|
||||||
|
|
||||||
|
class WindowsAcceptanceTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.path = Path(self.temp.name)
|
||||||
|
self.manifest = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"projects": ["Example.Tests"],
|
||||||
|
"groups": [{
|
||||||
|
"id": "example", "title": "Example",
|
||||||
|
"required_tests": [{"project": "Example.Tests", "method": "Example.Tests.Form.Accept", "minimum_cases": 2}],
|
||||||
|
"remaining_acceptance": ["Visual/operator check"],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
self.rows = [("Example.Tests.Form.Accept", "Passed"), ("Example.Tests.Form.Accept", "Passed")]
|
||||||
|
|
||||||
|
def write_trx(self, rows=None, total=None, summary="Completed"):
|
||||||
|
rows = self.rows if rows is None else rows
|
||||||
|
root = ET.Element("TestRun", xmlns="http://microsoft.com/schemas/VisualStudio/TeamTest/2010")
|
||||||
|
definitions = ET.SubElement(root, "TestDefinitions")
|
||||||
|
results = ET.SubElement(root, "Results")
|
||||||
|
for index, (method, outcome) in enumerate(rows):
|
||||||
|
test = ET.SubElement(definitions, "UnitTest", id=str(index))
|
||||||
|
class_name, name = method.rsplit(".", 1)
|
||||||
|
ET.SubElement(test, "TestMethod", className=class_name, name=name)
|
||||||
|
result = ET.SubElement(results, "UnitTestResult", testId=str(index), executionId=str(index),
|
||||||
|
testName=f"{method}(case: {index})", outcome=outcome)
|
||||||
|
if outcome != "Passed":
|
||||||
|
ET.SubElement(ET.SubElement(result, "Output"), "StdOut").text = "fixture reason"
|
||||||
|
result_summary = ET.SubElement(root, "ResultSummary", outcome=summary)
|
||||||
|
ET.SubElement(result_summary, "Counters", total=str(len(rows) if total is None else total),
|
||||||
|
executed=str(sum(r[1] != "NotExecuted" for r in rows)),
|
||||||
|
passed=str(sum(r[1] == "Passed" for r in rows)),
|
||||||
|
failed=str(sum(r[1] == "Failed" for r in rows)), notExecuted="0")
|
||||||
|
path = self.path / "Example.Tests.trx"
|
||||||
|
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def evaluate(self):
|
||||||
|
return acceptance.evaluate(self.manifest, self.path)
|
||||||
|
|
||||||
|
def test_complete_theory_passes_and_retains_manual_gate(self):
|
||||||
|
self.write_trx()
|
||||||
|
report = self.evaluate()
|
||||||
|
self.assertEqual("passed", report["automated_status"])
|
||||||
|
self.assertEqual(2, report["groups"][0]["result_count"])
|
||||||
|
self.assertEqual(["Visual/operator check"], report["groups"][0]["remaining_acceptance"])
|
||||||
|
self.assertEqual(2, report["suites"]["Example.Tests"]["total"])
|
||||||
|
|
||||||
|
def test_missing_or_malformed_file_fails(self):
|
||||||
|
self.assertEqual("failed", self.evaluate()["automated_status"])
|
||||||
|
(self.path / "Example.Tests.trx").write_text("<broken", encoding="utf-8")
|
||||||
|
self.assertEqual("failed", self.evaluate()["automated_status"])
|
||||||
|
|
||||||
|
def test_missing_method_or_theory_row_fails(self):
|
||||||
|
for rows in ([self.rows[0]], [("Example.Tests.Other.Accept", "Passed")] * 2):
|
||||||
|
with self.subTest(rows=rows):
|
||||||
|
self.write_trx(rows)
|
||||||
|
self.assertEqual("failed", self.evaluate()["automated_status"])
|
||||||
|
|
||||||
|
def test_skip_failed_aborted_and_unknown_required_results_fail(self):
|
||||||
|
for outcome in ("NotExecuted", "Failed", "Aborted", "TimedOut", "Unknown"):
|
||||||
|
with self.subTest(outcome=outcome):
|
||||||
|
self.write_trx([self.rows[0], (self.rows[1][0], outcome)])
|
||||||
|
report = self.evaluate()
|
||||||
|
self.assertEqual("failed", report["automated_status"])
|
||||||
|
self.assertEqual("fixture reason", report["groups"][0]["tests"][1]["detail"])
|
||||||
|
|
||||||
|
def test_unmapped_failed_test_still_fails_gate(self):
|
||||||
|
self.write_trx(self.rows + [("Example.Tests.Other.Failure", "Failed")])
|
||||||
|
self.assertEqual("failed", self.evaluate()["automated_status"])
|
||||||
|
|
||||||
|
def test_unmapped_skip_is_explicit_not_a_pass(self):
|
||||||
|
self.write_trx(self.rows + [("Example.Tests.Other.Optional", "NotExecuted")])
|
||||||
|
report = self.evaluate()
|
||||||
|
self.assertEqual("passed", report["automated_status"])
|
||||||
|
suite = report["suites"]["Example.Tests"]
|
||||||
|
self.assertEqual({"NotExecuted": 1, "Passed": 2}, suite["outcomes"])
|
||||||
|
self.assertEqual("fixture reason", suite["results"][2]["detail"])
|
||||||
|
|
||||||
|
def test_empty_or_inconsistent_counters_fail(self):
|
||||||
|
for rows, total in (([], 0), (self.rows, 3)):
|
||||||
|
self.write_trx(rows, total)
|
||||||
|
self.assertEqual("failed", self.evaluate()["automated_status"])
|
||||||
|
path = self.write_trx()
|
||||||
|
path.write_text(path.read_text().replace('passed="2"', 'passed="1"'))
|
||||||
|
self.assertEqual("failed", self.evaluate()["automated_status"])
|
||||||
|
|
||||||
|
def test_contradictory_or_incomplete_counters_fail(self):
|
||||||
|
changes = [("executed", "999"), ("executed", "-1"), ("executed", "0"),
|
||||||
|
("executed", "bad"), ("executed", None), ("notExecuted", "1"),
|
||||||
|
("completed", "999")]
|
||||||
|
changes += [(key, "1") for key in ("aborted", "error", "timeout", "inconclusive",
|
||||||
|
"passedButRunAborted", "notRunnable", "disconnected", "warning",
|
||||||
|
"inProgress", "pending", "unknownCounter")]
|
||||||
|
for key, value in changes:
|
||||||
|
with self.subTest(counter=key, value=value):
|
||||||
|
path = self.write_trx()
|
||||||
|
tree = ET.parse(path)
|
||||||
|
counters = tree.getroot().find(".//{*}Counters")
|
||||||
|
assert counters is not None
|
||||||
|
if value is None:
|
||||||
|
counters.attrib.pop(key)
|
||||||
|
else:
|
||||||
|
counters.set(key, value)
|
||||||
|
tree.write(path, encoding="utf-8", xml_declaration=True)
|
||||||
|
self.assertEqual("failed", self.evaluate()["automated_status"])
|
||||||
|
|
||||||
|
def test_aborted_run_fails_even_with_passed_rows(self):
|
||||||
|
self.write_trx(summary="Aborted")
|
||||||
|
self.assertEqual("failed", self.evaluate()["automated_status"])
|
||||||
|
|
||||||
|
def test_failed_summary_and_bad_counters_retain_readable_evidence(self):
|
||||||
|
rows = self.rows + [("Example.Tests.Other.Failure", "Failed"),
|
||||||
|
("Example.Tests.Other.Optional", "NotExecuted")]
|
||||||
|
for mode in ("Failed", "Aborted", "bad-counter", "missing-summary"):
|
||||||
|
with self.subTest(mode=mode):
|
||||||
|
path = self.write_trx(rows, summary=mode if mode in ("Failed", "Aborted") else "Completed")
|
||||||
|
tree = ET.parse(path)
|
||||||
|
root = tree.getroot()
|
||||||
|
summary = root.find("{*}ResultSummary")
|
||||||
|
assert summary is not None
|
||||||
|
if mode == "bad-counter":
|
||||||
|
counters = summary.find("{*}Counters")
|
||||||
|
assert counters is not None
|
||||||
|
counters.set("total", "999")
|
||||||
|
ET.SubElement(ET.SubElement(summary, "Output"), "StdOut").text = "runner failure detail"
|
||||||
|
if mode == "missing-summary":
|
||||||
|
root.remove(summary)
|
||||||
|
tree.write(path, encoding="utf-8", xml_declaration=True)
|
||||||
|
report = self.evaluate()
|
||||||
|
self.assertEqual("failed", report["automated_status"])
|
||||||
|
self.assertIn("Example.Tests", report["suites"])
|
||||||
|
suite = report["suites"]["Example.Tests"]
|
||||||
|
self.assertEqual(4, suite["total"])
|
||||||
|
self.assertEqual({"Passed": 2, "Failed": 1, "NotExecuted": 1}, suite["outcomes"])
|
||||||
|
self.assertTrue(suite["validation_errors"])
|
||||||
|
self.assertEqual("fixture reason", suite["results"][2]["detail"])
|
||||||
|
self.assertEqual("fixture reason", suite["results"][3]["detail"])
|
||||||
|
if mode != "missing-summary":
|
||||||
|
self.assertIn("runner failure detail", suite["summary_output"])
|
||||||
|
self.assertEqual("failed", report["groups"][0]["automated_status"])
|
||||||
|
|
||||||
|
def test_duplicate_or_unmapped_execution_fails(self):
|
||||||
|
for old, new in (('executionId="1"', 'executionId="0"'),
|
||||||
|
('testId="1"', 'testId="not-defined"'), ('id="1"', 'id="0"')):
|
||||||
|
path = self.write_trx()
|
||||||
|
path.write_text(path.read_text().replace(old, new))
|
||||||
|
self.assertEqual("failed", self.evaluate()["automated_status"])
|
||||||
|
|
||||||
|
def test_invalid_manifests_are_rejected(self):
|
||||||
|
original = copy.deepcopy(self.manifest)
|
||||||
|
for change in (
|
||||||
|
lambda m: m.update(projects=[]),
|
||||||
|
lambda m: m.update(groups=[]),
|
||||||
|
lambda m: m["groups"].append(copy.deepcopy(m["groups"][0])),
|
||||||
|
lambda m: m["groups"][0].update(required_tests=[]),
|
||||||
|
lambda m: m["groups"][0]["required_tests"][0].update(minimum_cases=0),
|
||||||
|
lambda m: m["groups"][0]["required_tests"][0].update(project="Unknown.Tests"),
|
||||||
|
):
|
||||||
|
self.manifest = copy.deepcopy(original)
|
||||||
|
change(self.manifest)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.evaluate()
|
||||||
|
|
||||||
|
def test_cli_refuses_linux_runtime_and_dirty_source(self):
|
||||||
|
self.write_trx()
|
||||||
|
manifest = self.path / "manifest.json"
|
||||||
|
manifest.write_text(json.dumps(self.manifest), encoding="utf-8")
|
||||||
|
args = ["--results", str(self.path), "--manifest", str(manifest)]
|
||||||
|
for system, dirty in (("Linux", ""), ("Windows", " M source.cs")):
|
||||||
|
with self.subTest(system=system, dirty=dirty), \
|
||||||
|
patch.object(acceptance.platform, "system", return_value=system), \
|
||||||
|
patch.object(acceptance.platform, "platform", return_value="Synthetic parser fixture"), \
|
||||||
|
patch.object(acceptance.subprocess, "check_output", side_effect=["a" * 40, "b" * 40, dirty]), \
|
||||||
|
patch.dict(acceptance.os.environ, {"GITHUB_STEP_SUMMARY": ""}), \
|
||||||
|
patch("builtins.print"):
|
||||||
|
self.assertEqual(1, acceptance.main(args))
|
||||||
|
report = json.loads((self.path / "windows-acceptance.json").read_text())
|
||||||
|
self.assertEqual("failed", report["automated_status"])
|
||||||
|
self.assertTrue(report["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fail-closed evidence for named Windows tests, not automatic feature sign-off."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
def read_suite(path):
|
||||||
|
"""Read actual result rows; TRX notExecuted counters can omit xUnit skips."""
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
if root.tag != "{http://microsoft.com/schemas/VisualStudio/TeamTest/2010}TestRun":
|
||||||
|
raise ValueError(f"{path.name}: not a TRX TestRun")
|
||||||
|
definitions = {}
|
||||||
|
for test in root.findall("./{*}TestDefinitions/{*}UnitTest"):
|
||||||
|
method = test.find("{*}TestMethod")
|
||||||
|
identity = test.attrib["id"]
|
||||||
|
if identity in definitions or method is None:
|
||||||
|
raise ValueError(f"{path.name}: duplicate or incomplete test definition")
|
||||||
|
definitions[identity] = method.attrib["className"] + "." + method.attrib["name"]
|
||||||
|
results = []
|
||||||
|
executions = set()
|
||||||
|
for result in root.findall("./{*}Results/{*}UnitTestResult"):
|
||||||
|
execution = result.attrib["executionId"]
|
||||||
|
if execution in executions:
|
||||||
|
raise ValueError(f"{path.name}: duplicate test execution")
|
||||||
|
executions.add(execution)
|
||||||
|
output = result.find("{*}Output")
|
||||||
|
results.append({
|
||||||
|
"method": definitions[result.attrib["testId"]],
|
||||||
|
"name": result.attrib["testName"],
|
||||||
|
"outcome": result.attrib["outcome"],
|
||||||
|
"detail": "\n".join(output.itertext()).strip() if output is not None else "",
|
||||||
|
})
|
||||||
|
summary = root.find("{*}ResultSummary")
|
||||||
|
validation_errors = []
|
||||||
|
try:
|
||||||
|
validate_summary(path, summary, results)
|
||||||
|
except (ValueError, KeyError) as exc:
|
||||||
|
validation_errors.append(str(exc))
|
||||||
|
output = summary.find("{*}Output") if summary is not None else None
|
||||||
|
return {
|
||||||
|
"file": path.name,
|
||||||
|
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||||
|
"total": len(results),
|
||||||
|
"outcomes": dict(sorted(Counter(r["outcome"] for r in results).items())),
|
||||||
|
"results": results,
|
||||||
|
"summary_outcome": summary.attrib.get("outcome") if summary is not None else None,
|
||||||
|
"summary_output": "\n".join(output.itertext()).strip() if output is not None else "",
|
||||||
|
"validation_errors": validation_errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_summary(path, summary, results):
|
||||||
|
if summary is None or summary.attrib.get("outcome") not in ("Completed", "Passed"):
|
||||||
|
raise ValueError(f"{path.name}: missing or unsuccessful run summary")
|
||||||
|
counters = summary.find("{*}Counters")
|
||||||
|
if counters is None or not results or int(counters.attrib["total"]) != len(results):
|
||||||
|
raise ValueError(f"{path.name}: empty results or total does not match enumerated results")
|
||||||
|
values = {key: int(value) for key, value in counters.attrib.items()}
|
||||||
|
for key, value in values.items():
|
||||||
|
if value < 0 or value > len(results):
|
||||||
|
raise ValueError(f"{path.name}: impossible {key} counter")
|
||||||
|
if key not in ("total", "executed", "passed", "failed", "notExecuted", "completed") and value:
|
||||||
|
raise ValueError(f"{path.name}: nonzero failure/incomplete counter {key}")
|
||||||
|
outcomes = Counter(r["outcome"] for r in results)
|
||||||
|
expected = {"passed": outcomes["Passed"], "failed": outcomes["Failed"],
|
||||||
|
"executed": len(results) - outcomes["NotExecuted"]}
|
||||||
|
for key, value in expected.items():
|
||||||
|
if values[key] != value:
|
||||||
|
raise ValueError(f"{path.name}: {key} counter does not match result rows")
|
||||||
|
# xUnit can emit skip rows with notExecuted=0. No other contradiction
|
||||||
|
# gets that exception, and required skips still fail in evaluate().
|
||||||
|
if values.get("notExecuted", 0) not in (0, outcomes["NotExecuted"]):
|
||||||
|
raise ValueError(f"{path.name}: notExecuted counter does not match skip rows")
|
||||||
|
if values.get("completed", 0) not in (0, values["executed"]):
|
||||||
|
raise ValueError(f"{path.name}: completed counter does not match executed rows")
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(manifest, results_dir):
|
||||||
|
if manifest.get("schema_version") != 1:
|
||||||
|
raise ValueError("Unsupported acceptance manifest schema")
|
||||||
|
projects = manifest["projects"]
|
||||||
|
groups = manifest["groups"]
|
||||||
|
if not projects or len(projects) != len(set(projects)) or not groups:
|
||||||
|
raise ValueError("Manifest must contain unique projects and nonempty groups")
|
||||||
|
if any(Path(p).name != p or not p.endswith(".Tests") for p in projects):
|
||||||
|
raise ValueError("Invalid test project name")
|
||||||
|
ids = [g["id"] for g in groups]
|
||||||
|
if len(ids) != len(set(ids)):
|
||||||
|
raise ValueError("Duplicate acceptance group")
|
||||||
|
suites, errors = {}, []
|
||||||
|
for project in projects:
|
||||||
|
try:
|
||||||
|
suites[project] = read_suite(results_dir / f"{project}.trx")
|
||||||
|
except (OSError, ET.ParseError, ValueError, KeyError) as exc:
|
||||||
|
errors.append(f"{project}: {exc}")
|
||||||
|
for project, suite in suites.items():
|
||||||
|
errors.extend(f"{project}: {error}" for error in suite["validation_errors"])
|
||||||
|
for result in suite["results"]:
|
||||||
|
if result["outcome"] not in ("Passed", "NotExecuted"):
|
||||||
|
errors.append(f"{project}: {result['name']} => {result['outcome']}")
|
||||||
|
reports = []
|
||||||
|
for group in groups:
|
||||||
|
requirements = group["required_tests"]
|
||||||
|
if not requirements:
|
||||||
|
raise ValueError(f"{group['id']}: empty required test list")
|
||||||
|
failures, matched = [], []
|
||||||
|
required_projects = {r["project"] for r in requirements}
|
||||||
|
for project in sorted(required_projects):
|
||||||
|
failures.extend(suites.get(project, {}).get("validation_errors", []))
|
||||||
|
seen = set()
|
||||||
|
for requirement in requirements:
|
||||||
|
project, method, minimum = (
|
||||||
|
requirement["project"], requirement["method"], requirement["minimum_cases"]
|
||||||
|
)
|
||||||
|
if project not in projects or not method.startswith(project + "."):
|
||||||
|
raise ValueError(f"{group['id']}: invalid project/method")
|
||||||
|
if type(minimum) is not int or minimum < 1 or (project, method) in seen:
|
||||||
|
raise ValueError(f"{group['id']}: duplicate test or invalid minimum_cases")
|
||||||
|
seen.add((project, method))
|
||||||
|
rows = [r for r in suites.get(project, {}).get("results", []) if r["method"] == method]
|
||||||
|
if len(rows) < minimum:
|
||||||
|
failures.append(f"{method}: expected at least {minimum} cases, found {len(rows)}")
|
||||||
|
for row in rows:
|
||||||
|
if row["outcome"] != "Passed":
|
||||||
|
failures.append(f"{row['name']}: {row['outcome']}")
|
||||||
|
matched.extend(rows)
|
||||||
|
reports.append({
|
||||||
|
"id": group["id"],
|
||||||
|
"title": group["title"],
|
||||||
|
"automated_status": "failed" if failures else "passed",
|
||||||
|
"result_count": len(matched),
|
||||||
|
"failures": failures,
|
||||||
|
"remaining_acceptance": group["remaining_acceptance"],
|
||||||
|
"tests": matched,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"automated_status": "failed" if errors or any(g["failures"] for g in reports) else "passed",
|
||||||
|
"errors": errors,
|
||||||
|
"suites": suites,
|
||||||
|
"groups": reports,
|
||||||
|
"notice": "Automated evidence only. Remaining acceptance is not waived; no tracker status is changed.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--results", type=Path, default=Path("TestResults"))
|
||||||
|
parser.add_argument("--manifest", type=Path, default=Path(__file__).with_name("windows-acceptance.json"))
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
report = evaluate(json.loads(args.manifest.read_text(encoding="utf-8")), args.results)
|
||||||
|
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||||
|
report = {"schema_version": 1, "automated_status": "failed", "errors": [str(exc)], "suites": {}, "groups": []}
|
||||||
|
source_root = Path(__file__).resolve().parent.parent
|
||||||
|
try:
|
||||||
|
def git(*arguments):
|
||||||
|
return subprocess.check_output(["git", *arguments], cwd=source_root, text=True).strip()
|
||||||
|
report["source"] = {"commit": git("rev-parse", "HEAD"), "tree": git("rev-parse", "HEAD^{tree}")}
|
||||||
|
if git("status", "--porcelain", "--untracked-files=no"):
|
||||||
|
raise ValueError("Tracked source differs from the committed tree")
|
||||||
|
except (OSError, subprocess.CalledProcessError, ValueError) as exc:
|
||||||
|
report["errors"].append(str(exc))
|
||||||
|
report["automated_status"] = "failed"
|
||||||
|
report["runtime"] = platform.platform()
|
||||||
|
report["created_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
report["workflow"] = {key: os.environ.get(key) for key in (
|
||||||
|
"GITHUB_REPOSITORY", "GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT", "GITHUB_SHA"
|
||||||
|
)}
|
||||||
|
if platform.system() != "Windows":
|
||||||
|
report["errors"].append("Windows acceptance must execute on Windows; cross-builds are not runtime evidence")
|
||||||
|
report["automated_status"] = "failed"
|
||||||
|
args.results.mkdir(parents=True, exist_ok=True)
|
||||||
|
(args.results / "windows-acceptance.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||||
|
lines = ["# Windows automated acceptance", "", report["automated_status"].upper(), "",
|
||||||
|
"Automated evidence only; this does not close tracker tasks or waive remaining acceptance.", ""]
|
||||||
|
lines.extend(f"- {error}" for error in report["errors"])
|
||||||
|
for project, suite in report["suites"].items():
|
||||||
|
lines.append(f"- {project}: {suite['total']} results, {suite['outcomes']}")
|
||||||
|
for group in report["groups"]:
|
||||||
|
lines.extend(["", f"## {group['title']}: {group['automated_status']} ({group['result_count']} results)"])
|
||||||
|
lines.extend(f"- FAIL: {f}" for f in group["failures"])
|
||||||
|
lines.extend(f"- Remaining: {r}" for r in group["remaining_acceptance"])
|
||||||
|
summary = "\n".join(lines) + "\n"
|
||||||
|
(args.results / "windows-acceptance.md").write_text(summary, encoding="utf-8")
|
||||||
|
if os.environ.get("GITHUB_STEP_SUMMARY"):
|
||||||
|
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as output:
|
||||||
|
output.write(summary)
|
||||||
|
print(summary)
|
||||||
|
return 0 if report["automated_status"] == "passed" else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user