mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 04:24:10 -04:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 ($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
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
publish:
|
||||
if: >-
|
||||
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'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
|
||||
@@ -49,6 +49,20 @@ jobs:
|
||||
run: |
|
||||
dotnet build OpenNest.sln -c Release
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
@@ -32,13 +32,18 @@ namespace OpenNest
|
||||
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];
|
||||
nextColorIndex++;
|
||||
return color;
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||
return UseGoldenAngleColors
|
||||
? PartColorPalette.GoldenAngle(index)
|
||||
: PartColors[index % PartColors.Length];
|
||||
}
|
||||
|
||||
public static Color GetNextColor() => GetPartColor(unchecked(nextColorIndex++) & int.MaxValue);
|
||||
|
||||
public Drawing()
|
||||
: 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,20 @@ namespace OpenNest.Data;
|
||||
/// 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
|
||||
/// 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>
|
||||
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 Dictionary<Guid, NestDetails> _cache = new();
|
||||
private readonly List<Guid> _cacheOrder = new();
|
||||
private readonly Dictionary<Guid, CancellationTokenSource> _prefetches = new();
|
||||
private CancellationTokenSource? _pending;
|
||||
private int _generation;
|
||||
private bool _disposed;
|
||||
@@ -49,7 +58,18 @@ public sealed class NestDetailsSession : IDisposable
|
||||
|
||||
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)
|
||||
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()
|
||||
{
|
||||
if (_disposed)
|
||||
@@ -83,6 +159,8 @@ public sealed class NestDetailsSession : IDisposable
|
||||
Details = null;
|
||||
IsLoading = false;
|
||||
pending?.Cancel();
|
||||
foreach (var prefetch in _prefetches.Values)
|
||||
prefetch.Cancel();
|
||||
}
|
||||
|
||||
private CancellationTokenSource Supersede()
|
||||
|
||||
@@ -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,
|
||||
CancellationToken token)
|
||||
CancellationToken token, Action<CuttingDependencyException> uncertain = null)
|
||||
{
|
||||
var edges = nodes.Select(_ => new SortedSet<int>()).ToArray();
|
||||
for (var cut = 0; cut < nodes.Count; cut++)
|
||||
@@ -98,9 +98,24 @@ internal sealed class CuttingDependencyGraph
|
||||
token.ThrowIfCancellationRequested();
|
||||
// Bounds only select candidates; containment itself is proven on native material.
|
||||
if (inner != host && !nodes[inner].IsCutOff && !nodes[host].IsCutOff
|
||||
&& Within(nodes[inner].CleanBounds, nodes[host].HostBounds)
|
||||
&& InsideCutout(nodes, inner, host, token))
|
||||
edges[host].Add(inner);
|
||||
&& Within(nodes[inner].CleanBounds, nodes[host].HostBounds))
|
||||
{
|
||||
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());
|
||||
if (graph.FindCycle() is { } cyclic)
|
||||
|
||||
@@ -99,7 +99,11 @@ public sealed class CuttingPlanBatch
|
||||
var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
||||
expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), 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);
|
||||
}
|
||||
@@ -112,7 +116,8 @@ public sealed class CuttingPlanBatch
|
||||
/// 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
|
||||
/// 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>
|
||||
public CuttingPlanProposal Plan(IProgress<CuttingPlanProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
@@ -140,19 +145,31 @@ public sealed class CuttingPlanBatch
|
||||
reorder = CuttingPlanService.Plan(entry.Reorder, token);
|
||||
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;
|
||||
}
|
||||
}
|
||||
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.KeepingOrder));
|
||||
plans[index] = new(entry.Plate, entry.Number, CuttingPlanService.Plan(entry.KeepOrder, token), reorder,
|
||||
overlap);
|
||||
plans[index] = new(entry.Plate, entry.Number,
|
||||
WithFallback(entry, CuttingPlanService.Plan(entry.KeepOrder, token), token), reorder, overlap);
|
||||
}
|
||||
return new(plans, ownedParameters);
|
||||
}
|
||||
|
||||
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,
|
||||
PlateOverlapSnapshot Overlap);
|
||||
PlateOverlapSnapshot Overlap, CuttingPlanSnapshot Fallback);
|
||||
}
|
||||
|
||||
/// <summary>One plate's outcome inside a <see cref="CuttingPlanProposal"/>.</summary>
|
||||
@@ -188,6 +205,10 @@ public sealed class CuttingPlanPlateResult
|
||||
|
||||
public bool IsOverlapClear => Overlap is { IsComplete: true } && Overlap.Pairs.Count == 0;
|
||||
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 RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated);
|
||||
|
||||
@@ -196,7 +217,7 @@ public sealed class CuttingPlanPlateResult
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public sealed class CuttingPlanProposal
|
||||
{
|
||||
@@ -216,17 +237,26 @@ public sealed class CuttingPlanProposal
|
||||
public bool CanApply => Plates.Count > 0 && !IsCancelled && ownedParameters != null
|
||||
&& 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>
|
||||
/// 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
|
||||
/// of the confirmed parameters as its cutting settings; any other status changes nothing.
|
||||
/// </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,
|
||||
"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)
|
||||
foreach (var plate in Plates)
|
||||
plate.Plate.CuttingParameters = OwnedCuttingParameters.Copy(ownedParameters);
|
||||
@@ -234,18 +264,17 @@ public sealed class CuttingPlanProposal
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A detached copy of a ready plate for display, in the proposed order with the proposed
|
||||
/// programs, or null. A refused plate is not previewed: its program graphs may be unsafe to
|
||||
/// copy. Neither is a plate that changed after planning, which would draw replayed programs at
|
||||
/// poses that were never checked. The copy has quantity zero, so drawing quantities stay put.
|
||||
/// A detached copy of a ready or explicitly unverified proposal for display, or null.
|
||||
/// Refused graphs are never cloned. Changed plates are not previewed at uncaptured poses.
|
||||
/// The copy has quantity zero, so drawing quantities stay put.
|
||||
/// Call it on the thread that owns the plates.
|
||||
/// </summary>
|
||||
public Plate BuildPreview(int 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;
|
||||
// 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 preview = new Plate(source.Size)
|
||||
{
|
||||
@@ -274,6 +303,9 @@ public sealed class CuttingPlanProposal
|
||||
lines.Add($"Ready to apply to {Count(Plates.Count, "plate")}: "
|
||||
+ $"{Count(Plates.Count(p => p.OrderChanged), "plate")} with a new part order, "
|
||||
+ $"{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
|
||||
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.");
|
||||
@@ -307,6 +339,17 @@ public sealed class CuttingPlanProposal
|
||||
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
|
||||
&& result.Findings.Any(f => f.Kind == PostVerificationKind.MissingLeadIn);
|
||||
yield return heading + (plate.IsRouteReady
|
||||
|
||||
@@ -60,8 +60,9 @@ public sealed class CuttingPlanSnapshot
|
||||
int expansionBudget, CuttingPlanStatus? failure = null, IEnumerable<CuttingPlanFinding> findings = null,
|
||||
bool regeneration = false, bool preservePartOrder = false, int maxEntries = 16,
|
||||
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;
|
||||
OwnedParameters = ownedParameters;
|
||||
Placements = Array.AsReadOnly(placements.ToArray());
|
||||
@@ -82,6 +83,7 @@ public sealed class CuttingPlanSnapshot
|
||||
internal PlateCuttingState PlateState { get; }
|
||||
/// <summary>Owned copy of the confirmed parameters taken at capture; never the caller's object.</summary>
|
||||
internal CuttingParameters OwnedParameters { get; }
|
||||
internal bool BestEffort { get; }
|
||||
internal bool Regeneration { get; }
|
||||
internal bool PreservePartOrder { get; }
|
||||
internal int MaxEntries { get; }
|
||||
@@ -159,7 +161,9 @@ public enum CuttingPlanStatus
|
||||
UnsupportedGeometry,
|
||||
InvalidInput,
|
||||
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>
|
||||
@@ -169,7 +173,8 @@ public sealed record CuttingPlanFinding(int? SourceOrdinal, Part SourcePart,
|
||||
/// <summary>
|
||||
/// A replayed direct-XY proposal, optionally with regenerated programs, that respects the
|
||||
/// 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>
|
||||
public sealed class CuttingPlanResult
|
||||
{
|
||||
|
||||
@@ -15,9 +15,13 @@ public static class CuttingPlanService
|
||||
/// Read stable caller-owned sources once into privately owned programs and geometry.
|
||||
/// No private Plates, settings aliases, quantity updates or source subcall rebinding.
|
||||
/// </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 warnings = new List<CuttingPlanFinding>();
|
||||
Part source = null;
|
||||
int? ordinal = null;
|
||||
try
|
||||
@@ -107,7 +111,11 @@ public static class CuttingPlanService
|
||||
{
|
||||
material = LeadMaterialSnapshot.Capture(ownedClean, location, token);
|
||||
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)))
|
||||
prepared = PreparedContours.Capture(ownedClean, request.ConfirmedParameters, token);
|
||||
}
|
||||
@@ -122,13 +130,16 @@ public static class CuttingPlanService
|
||||
placements[0].Execution.RapidDistanceFrom(request.StartPoint);
|
||||
source = 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,
|
||||
preservePartOrder: request.PreservePartOrder, maxEntries: request.MaxEntries,
|
||||
expansionObserver: request.ExpansionObserver, plateState: plateState,
|
||||
ownedParameters: request.ConfirmedParameters == null ? null
|
||||
: OwnedCuttingParameters.Copy(request.ConfirmedParameters),
|
||||
dependencies: dependencies);
|
||||
dependencies: dependencies, findings: warnings, bestEffort: bestEffort);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -175,14 +186,15 @@ public static class CuttingPlanService
|
||||
|
||||
// beforeInstall is the commit's install-boundary test seam.
|
||||
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>();
|
||||
foreach (var result in results ?? [])
|
||||
{
|
||||
var snapshot = result?.Snapshot;
|
||||
if (result?.Status != CuttingPlanStatus.Ready || !result.IndependentlyReplayed
|
||||
|| snapshot?.PlateState == null || result.ProposedOrder.Count != snapshot.Placements.Count)
|
||||
var eligible = result != null && (result.Status == CuttingPlanStatus.Ready && result.IndependentlyReplayed
|
||||
|| acceptUnverified && result.Status == CuttingPlanStatus.BestEffort);
|
||||
if (!eligible || snapshot?.PlateState == null || result.ProposedOrder.Count != snapshot.Placements.Count)
|
||||
return new(CuttingCommitStatus.InvalidInput,
|
||||
"Only Ready, independently replayed plate-scoped proposals can be applied.");
|
||||
var programs = new List<PlannedPartProgram>();
|
||||
@@ -211,6 +223,8 @@ public static class CuttingPlanService
|
||||
return new(CuttingPlanStatus.InvalidInput);
|
||||
if (snapshot.Failure is { } failure)
|
||||
return new(failure, findings: snapshot.Findings);
|
||||
if (snapshot.BestEffort)
|
||||
return BestEffortCuttingPlan.Plan(snapshot, token);
|
||||
if (snapshot.Placements.Count == 0)
|
||||
return snapshot.PlateState == null ? new(CuttingPlanStatus.InvalidInput)
|
||||
: new(CuttingPlanStatus.Ready, independentlyReplayed: true); // Empty plate: unchanged no-op.
|
||||
|
||||
@@ -290,6 +290,63 @@ namespace OpenNest.Engine.Fill
|
||||
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>
|
||||
/// Repeatedly pushes parts left then down until total movement per
|
||||
/// iteration falls below the given threshold.
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class NestDatabaseConcurrencyTests
|
||||
public async Task Concurrency_EveryOperationWaitsForTheSameMonitor(string operation)
|
||||
{
|
||||
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 record = new NestRecord { Name = "Monitor ownership fixture" };
|
||||
database.Insert(id, record, new byte[] { 1, 2, 3 });
|
||||
@@ -75,7 +75,7 @@ public sealed class NestDatabaseConcurrencyTests
|
||||
public void Concurrency_AllSqlStepsIncludingReadbacks_OwnTheMonitor()
|
||||
{
|
||||
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)!
|
||||
.GetValue(database)!;
|
||||
var connection = (SqliteConnection)typeof(NestDatabase)
|
||||
@@ -125,7 +125,7 @@ public sealed class NestDatabaseConcurrencyTests
|
||||
public async Task Concurrency_DatabaseReadersAndWriters_PreserveExactRecords()
|
||||
{
|
||||
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);
|
||||
var workers = Enumerable.Range(0, 4).Select(lane => Task.Factory.StartNew(() =>
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
@@ -16,6 +15,7 @@ namespace OpenNest.Server.Tests;
|
||||
public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
||||
{
|
||||
private bool _cleanedUp;
|
||||
private NestDatabase? _database;
|
||||
|
||||
public ServerFactory(string? databasePath = null)
|
||||
{
|
||||
@@ -34,7 +34,8 @@ public sealed class ServerFactory : WebApplicationFactory<global::Program>
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
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;
|
||||
|
||||
_cleanedUp = true;
|
||||
// Pooled connections would keep the file open on Windows after the host disposed it.
|
||||
SqliteConnection.ClearAllPools();
|
||||
_database?.Dispose();
|
||||
if (Directory.Exists(DirectoryPath))
|
||||
Directory.Delete(DirectoryPath, recursive: true);
|
||||
}
|
||||
|
||||
@@ -16,13 +16,18 @@ public sealed class NestDatabase : IDisposable
|
||||
private readonly object _sync = new();
|
||||
private readonly SqliteConnection _connection;
|
||||
|
||||
public NestDatabase(string databasePath)
|
||||
public NestDatabase(string databasePath, bool pooling = true)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(databasePath));
|
||||
if (!string.IsNullOrEmpty(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();
|
||||
Execute("""
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,74 @@ public class NestDetailsSessionTests
|
||||
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>
|
||||
private sealed class ControlledLoads
|
||||
{
|
||||
|
||||
@@ -527,6 +527,72 @@ namespace OpenNest.Tests.Fill
|
||||
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]
|
||||
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) =>
|
||||
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)
|
||||
{
|
||||
var pages = Run("pdftotext", "-layout", path, "-").Split('\f');
|
||||
// pdftotext terminates the last page with a form feed.
|
||||
return pages.Take(pages.Length - 1).Select(page => Regex.Replace(page, "[ \t]+\n", "\n")).ToArray();
|
||||
var html = Run("pdftotext", "-bbox", path, "-");
|
||||
return Regex.Matches(html, @"<page\b[^>]*>.*?</page>", RegexOptions.Singleline)
|
||||
.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>
|
||||
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>")
|
||||
.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)))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,216 @@
|
||||
using System.Data;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Forms;
|
||||
using OpenNest.IO.Bom;
|
||||
|
||||
namespace OpenNest.WinForms.Tests.Forms;
|
||||
|
||||
[Collection("Fill operation lifetime")]
|
||||
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]
|
||||
public void PartColumns_KeepBomOrderAndOnlyMaterialThicknessAndQtyAreEditable()
|
||||
{
|
||||
|
||||
@@ -227,6 +227,41 @@ public class CuttingPlanFormTests
|
||||
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)
|
||||
{
|
||||
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;
|
||||
|
||||
namespace OpenNest.WinForms.Tests.Forms;
|
||||
|
||||
[Collection("Fill operation lifetime")]
|
||||
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]
|
||||
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.Linq;
|
||||
using System.Windows.Forms;
|
||||
@@ -178,11 +178,7 @@ namespace OpenNest.Actions
|
||||
break;
|
||||
}
|
||||
|
||||
Compactor.PushBoundingBox(movingParts, plateView.Plate, hDir);
|
||||
Compactor.PushBoundingBox(movingParts, plateView.Plate, vDir);
|
||||
|
||||
Compactor.Push(movingParts, plateView.Plate, hDir);
|
||||
Compactor.Push(movingParts, plateView.Plate, vDir);
|
||||
Compactor.SettlePlacement(movingParts, plateView.Plate, hDir, vDir);
|
||||
|
||||
parts.ForEach(p => p.IsDirty = true);
|
||||
plateView.Invalidate();
|
||||
|
||||
+14
-1
@@ -1,4 +1,4 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
|
||||
namespace OpenNest
|
||||
@@ -17,6 +17,19 @@ namespace OpenNest
|
||||
|
||||
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
|
||||
{
|
||||
Name = "Classic",
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace OpenNest
|
||||
["Classic"] = BuildClassic(),
|
||||
["Pastel"] = BuildPastel(),
|
||||
["Dark"] = BuildDark(),
|
||||
["Workshop"] = BuildWorkshop(),
|
||||
};
|
||||
|
||||
private static List<ColorScheme> diskCache;
|
||||
@@ -64,13 +65,18 @@ namespace OpenNest
|
||||
d.EdgeSpacingColor = scheme.EdgeSpacingColor;
|
||||
d.PreviewPartColor = scheme.PreviewPartColor;
|
||||
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.UseGoldenAngleColors = scheme.UseGoldenAngleColors;
|
||||
|
||||
RecolorOpenNests(scheme.PartColors);
|
||||
RecolorOpenNests();
|
||||
}
|
||||
|
||||
private static void RecolorOpenNests(Color[] palette)
|
||||
private static void RecolorOpenNests()
|
||||
{
|
||||
foreach (Form f in Application.OpenForms)
|
||||
{
|
||||
@@ -82,7 +88,7 @@ namespace OpenNest
|
||||
{
|
||||
if (drawing.IsCutOff)
|
||||
continue;
|
||||
drawing.Color = palette[i % palette.Length];
|
||||
drawing.Color = Drawing.GetPartColor(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() =>
|
||||
new ColorScheme
|
||||
{
|
||||
|
||||
@@ -26,7 +26,11 @@ namespace OpenNest
|
||||
OriginColor = ToHex(scheme.OriginColor),
|
||||
EdgeSpacingColor = ToHex(scheme.EdgeSpacingColor),
|
||||
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(),
|
||||
UseGoldenAngleColors = scheme.UseGoldenAngleColors,
|
||||
};
|
||||
return JsonSerializer.Serialize(dto, JsonOptions);
|
||||
}
|
||||
@@ -48,7 +52,11 @@ namespace OpenNest
|
||||
OriginColor = FromHex(dto.OriginColor),
|
||||
EdgeSpacingColor = FromHex(dto.EdgeSpacingColor),
|
||||
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(),
|
||||
UseGoldenAngleColors = dto.UseGoldenAngleColors,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,7 +99,11 @@ namespace OpenNest
|
||||
public string OriginColor { get; set; }
|
||||
public string EdgeSpacingColor { 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 bool UseGoldenAngleColors { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
using Size = System.Drawing.Size;
|
||||
|
||||
namespace OpenNest.Controls
|
||||
{
|
||||
using Size = System.Drawing.Size;
|
||||
|
||||
public class DrawingListBox : ListBox
|
||||
{
|
||||
private const int WM_ERASEBKGND = 0x0014;
|
||||
@@ -98,9 +97,9 @@ namespace OpenNest.Controls
|
||||
var pt = new PointF(5, e.Bounds.Y + 5);
|
||||
|
||||
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();
|
||||
brush.Dispose();
|
||||
|
||||
@@ -118,10 +118,7 @@ namespace OpenNest.Controls
|
||||
if (part.IsDirty)
|
||||
part.Update(view);
|
||||
|
||||
var path = part.Path;
|
||||
var pathBounds = path.GetBounds();
|
||||
|
||||
if (!pathBounds.IntersectsWith(viewBounds))
|
||||
if (!part.DisplayBounds.IntersectsWith(viewBounds))
|
||||
continue;
|
||||
|
||||
part.Draw(g, (i + 1).ToString());
|
||||
@@ -142,10 +139,11 @@ namespace OpenNest.Controls
|
||||
part.Update(view);
|
||||
|
||||
var path = part.Path;
|
||||
if (!path.GetBounds().IntersectsWith(viewBounds))
|
||||
if (!part.DisplayBounds.IntersectsWith(viewBounds))
|
||||
continue;
|
||||
|
||||
g.FillPath(previewBrush, path);
|
||||
part.DrawEtch(g);
|
||||
g.DrawPath(previewPen, path);
|
||||
}
|
||||
|
||||
|
||||
@@ -816,7 +816,8 @@ namespace OpenNest.Controls
|
||||
|
||||
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];
|
||||
break;
|
||||
|
||||
@@ -208,7 +208,7 @@ namespace OpenNest.Controls
|
||||
var parts = view.LayoutParts;
|
||||
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 null;
|
||||
@@ -230,10 +230,12 @@ namespace OpenNest.Controls
|
||||
for (var i = 0; i < parts.Count; ++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))
|
||||
list.Add(part);
|
||||
region.Dispose();
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -241,8 +243,8 @@ namespace OpenNest.Controls
|
||||
for (var i = 0; i < parts.Count; ++i)
|
||||
{
|
||||
var part = parts[i];
|
||||
var bounds = part.Path.GetBounds();
|
||||
if (rect.Contains(bounds))
|
||||
var path = part.SelectionPath;
|
||||
if (path != null && rect.Contains(path.GetBounds()))
|
||||
list.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -25,6 +25,7 @@ partial class CuttingPlanForm
|
||||
settingsButton = new Button();
|
||||
optionsPanel = new FlowLayoutPanel();
|
||||
keepOrderCheckBox = new CheckBox();
|
||||
acceptWarningsCheckBox = new CheckBox();
|
||||
planButton = new Button();
|
||||
split = new SplitContainer();
|
||||
previewLayout = new TableLayoutPanel();
|
||||
@@ -54,15 +55,17 @@ partial class CuttingPlanForm
|
||||
layout.Controls.Add(optionsPanel, 0, 1);
|
||||
layout.Controls.Add(split, 0, 2);
|
||||
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.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.Percent, 100F));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.TabIndex = 0;
|
||||
//
|
||||
// settingsPanel
|
||||
@@ -190,6 +193,16 @@ partial class CuttingPlanForm
|
||||
statusLabel.Name = "statusLabel";
|
||||
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.AutoSize = true;
|
||||
@@ -199,7 +212,7 @@ partial class CuttingPlanForm
|
||||
buttonsPanel.FlowDirection = FlowDirection.RightToLeft;
|
||||
buttonsPanel.Margin = new Padding(0, 8, 0, 0);
|
||||
buttonsPanel.Name = "buttonsPanel";
|
||||
buttonsPanel.TabIndex = 4;
|
||||
buttonsPanel.TabIndex = 5;
|
||||
buttonsPanel.WrapContents = false;
|
||||
//
|
||||
// applyButton
|
||||
@@ -264,6 +277,7 @@ partial class CuttingPlanForm
|
||||
private Button settingsButton;
|
||||
private FlowLayoutPanel optionsPanel;
|
||||
private CheckBox keepOrderCheckBox;
|
||||
private CheckBox acceptWarningsCheckBox;
|
||||
private Button planButton;
|
||||
private SplitContainer split;
|
||||
private TableLayoutPanel previewLayout;
|
||||
|
||||
@@ -57,6 +57,8 @@ public partial class CuttingPlanForm : Form
|
||||
this.isOperationBusy = isOperationBusy;
|
||||
unit = UnitsHelper.GetShortString(nest.Units);
|
||||
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
|
||||
// uninstalls its ambient context, so progress and results must not depend on whichever
|
||||
// context is current when planning starts.
|
||||
@@ -241,7 +243,11 @@ public partial class CuttingPlanForm : Form
|
||||
{
|
||||
proposal = result;
|
||||
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;
|
||||
var index = Array.IndexOf(plates, activePlate);
|
||||
var plate = index < 0 ? null : result.BuildPreview(index);
|
||||
@@ -252,7 +258,7 @@ public partial class CuttingPlanForm : Form
|
||||
+ "match the editor.";
|
||||
return;
|
||||
}
|
||||
previewLabel.Text = previewText;
|
||||
previewLabel.Text = result.Plates[index].IsReady ? previewText : "UNVERIFIED — " + previewText;
|
||||
preview.Plate = plate;
|
||||
preview.Visible = true;
|
||||
preview.ZoomToFit();
|
||||
@@ -261,6 +267,8 @@ public partial class CuttingPlanForm : Form
|
||||
private void ClearProposal()
|
||||
{
|
||||
proposal = null;
|
||||
acceptWarningsCheckBox.Checked = false;
|
||||
acceptWarningsCheckBox.Visible = false;
|
||||
applyButton.Enabled = false;
|
||||
preview.Visible = false;
|
||||
previewLabel.Text = previewText;
|
||||
@@ -277,6 +285,7 @@ public partial class CuttingPlanForm : Form
|
||||
{
|
||||
settingsButton.Enabled = !running;
|
||||
keepOrderCheckBox.Enabled = !running;
|
||||
acceptWarningsCheckBox.Enabled = !running;
|
||||
planButton.Enabled = !running;
|
||||
if (running)
|
||||
applyButton.Enabled = false;
|
||||
@@ -334,9 +343,15 @@ public partial class CuttingPlanForm : Form
|
||||
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)
|
||||
{
|
||||
if (planning != null || proposal?.CanApply != true)
|
||||
if (planning != null || !CanApplyProposal)
|
||||
return;
|
||||
if (!TryCheckCanChange(out var reason))
|
||||
{
|
||||
@@ -344,7 +359,7 @@ public partial class CuttingPlanForm : Form
|
||||
return;
|
||||
}
|
||||
|
||||
var commit = proposal.Apply();
|
||||
var commit = proposal.Apply(acceptWarningsCheckBox.Checked);
|
||||
CommitResult = commit;
|
||||
switch (commit.Status)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace OpenNest.Forms
|
||||
@@ -45,7 +45,8 @@ namespace OpenNest.Forms
|
||||
private void UpdateImage()
|
||||
{
|
||||
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);
|
||||
|
||||
pen.Dispose();
|
||||
|
||||
@@ -1248,16 +1248,21 @@ namespace OpenNest.Forms
|
||||
{
|
||||
form.SaveDrawing(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.Color = drawing.Color;
|
||||
RefreshDrawingColor(drawing);
|
||||
|
||||
UpdateDrawingList();
|
||||
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)
|
||||
{
|
||||
var result = MessageBox.Show(
|
||||
|
||||
@@ -22,6 +22,11 @@ namespace OpenNest.Forms;
|
||||
/// </summary>
|
||||
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 PlateSizeColumn = "PlateSize";
|
||||
private const string AreaColumn = "Area";
|
||||
@@ -571,6 +576,7 @@ public sealed class SavedNestsForm : Form
|
||||
if (details.Details != null)
|
||||
{
|
||||
PopulateDetails(details.Details);
|
||||
PrefetchNearby(id.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -590,7 +596,10 @@ public sealed class SavedNestsForm : Form
|
||||
try
|
||||
{
|
||||
if (await details.LoadAsync(id) && !IsDisposed)
|
||||
{
|
||||
PopulateDetails(details.Details);
|
||||
PrefetchNearby(id);
|
||||
}
|
||||
}
|
||||
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>
|
||||
private async Task<NestDetails> LoadDetailsAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
+54
-14
@@ -39,6 +39,33 @@ namespace OpenNest
|
||||
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)
|
||||
{
|
||||
return pgm.GetImage(size, Pens.Black, null);
|
||||
@@ -57,26 +84,34 @@ namespace OpenNest
|
||||
)
|
||||
{
|
||||
var img = new Bitmap(size.Width, size.Height);
|
||||
var path = pgm.GetGraphicsPath();
|
||||
var bounds = path.GetBounds();
|
||||
pgm.GetDisplayPaths(Vector.Zero, out var cuts, out var leads, out var etches);
|
||||
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 scaley = (size.Width - 10) / bounds.Width;
|
||||
var scalex = (size.Height - 10) / System.Math.Max(bounds.Height, 0.001f);
|
||||
var scaley = (size.Width - 10) / System.Math.Max(bounds.Width, 0.001f);
|
||||
var scale = scalex < scaley ? scalex : scaley;
|
||||
|
||||
var matrix = new Matrix();
|
||||
using var matrix = new Matrix();
|
||||
matrix.Scale(scale, -scale);
|
||||
|
||||
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(
|
||||
(size.Width - bounds.Width) * 0.5f - bounds.X,
|
||||
(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);
|
||||
|
||||
if (brush != null)
|
||||
@@ -85,11 +120,11 @@ namespace OpenNest
|
||||
if (pen == null)
|
||||
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);
|
||||
|
||||
matrix.Dispose();
|
||||
graphics.Dispose();
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
@@ -282,7 +317,8 @@ namespace OpenNest
|
||||
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
|
||||
// to this fixed origin, not to the current tool position.
|
||||
@@ -309,7 +345,9 @@ namespace OpenNest
|
||||
case CodeType.ArcMove:
|
||||
{
|
||||
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)
|
||||
currentFigure = new GraphicsPath();
|
||||
@@ -329,7 +367,9 @@ namespace OpenNest
|
||||
case CodeType.LinearMove:
|
||||
{
|
||||
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)
|
||||
currentFigure = new GraphicsPath();
|
||||
@@ -372,7 +412,7 @@ namespace OpenNest
|
||||
frameOrigin.X + subpgm.Offset.X,
|
||||
frameOrigin.Y + subpgm.Offset.Y
|
||||
);
|
||||
AddProgram(path, subpgm.Program, mode, ref curpos);
|
||||
AddProgram(path, subpgm.Program, mode, ref curpos, includeLayer);
|
||||
}
|
||||
|
||||
mode = tmpmode;
|
||||
|
||||
+74
-58
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Linq;
|
||||
@@ -18,8 +18,10 @@ namespace OpenNest
|
||||
private static Pen leadInPen;
|
||||
|
||||
private Color color;
|
||||
private Color? colorOverride;
|
||||
private Brush brush;
|
||||
private Pen pen;
|
||||
private ColorScheme colorScheme = ColorScheme.Default;
|
||||
|
||||
private List<PointF[]> _offsetPolygonPoints;
|
||||
private double _cachedOffsetSpacing;
|
||||
@@ -45,7 +47,7 @@ namespace OpenNest
|
||||
if (part.BaseDrawing.Color.IsEmpty)
|
||||
part.BaseDrawing.Color = Color.FromArgb(130, 204, 130);
|
||||
|
||||
Color = part.BaseDrawing.Color;
|
||||
SetDisplayColor(part.BaseDrawing.Color);
|
||||
}
|
||||
|
||||
internal bool IsDirty { get; set; }
|
||||
@@ -56,37 +58,49 @@ namespace OpenNest
|
||||
|
||||
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
|
||||
{
|
||||
get { return color; }
|
||||
set
|
||||
{
|
||||
color = value;
|
||||
|
||||
if (brush != null)
|
||||
brush.Dispose();
|
||||
|
||||
brush = new SolidBrush(value);
|
||||
|
||||
if (pen != null)
|
||||
pen.Dispose();
|
||||
|
||||
pen = new Pen(ControlPaint.Dark(value));
|
||||
colorOverride = value;
|
||||
SetDisplayColor(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)
|
||||
{
|
||||
if (IsSelected)
|
||||
{
|
||||
g.FillPath(selectedBrush, Path);
|
||||
g.DrawPath(selectedPen, Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
g.FillPath(brush, Path);
|
||||
g.DrawPath(pen, Path);
|
||||
}
|
||||
RefreshColors();
|
||||
using var schemeSelectionBrush = IsSelected && !colorScheme.SelectedPartColor.IsEmpty
|
||||
? new SolidBrush(colorScheme.SelectedPartColor) : null;
|
||||
g.FillPath(schemeSelectionBrush ?? (IsSelected ? selectedBrush : brush), Path);
|
||||
DrawEtch(g);
|
||||
// Keep real cuts visible even where an etch overlaps them.
|
||||
g.DrawPath(IsSelected && colorScheme.PartOutlineColor.IsEmpty ? selectedPen : pen, Path);
|
||||
|
||||
if (LeadInPath != null)
|
||||
g.DrawPath(leadInPen, LeadInPath);
|
||||
@@ -94,19 +108,7 @@ namespace OpenNest
|
||||
|
||||
public void Draw(Graphics g, string id)
|
||||
{
|
||||
if (IsSelected)
|
||||
{
|
||||
g.FillPath(selectedBrush, Path);
|
||||
g.DrawPath(selectedPen, Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
g.FillPath(brush, Path);
|
||||
g.DrawPath(pen, Path);
|
||||
}
|
||||
|
||||
if (LeadInPath != null)
|
||||
g.DrawPath(leadInPen, LeadInPath);
|
||||
Draw(g);
|
||||
|
||||
using var sf = new StringFormat
|
||||
{
|
||||
@@ -125,10 +127,18 @@ namespace OpenNest
|
||||
|
||||
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()
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -158,26 +168,31 @@ namespace OpenNest
|
||||
|
||||
public void Update(DrawControl plateView)
|
||||
{
|
||||
if (BasePart.HasManualLeadIns)
|
||||
{
|
||||
BasePart.Program.GetGraphicsPaths(
|
||||
BasePart.Location,
|
||||
out var cutPath,
|
||||
out var leadPath
|
||||
);
|
||||
cutPath.Transform(plateView.Matrix);
|
||||
leadPath.Transform(plateView.Matrix);
|
||||
Path = cutPath;
|
||||
LeadInPath?.Dispose();
|
||||
LeadInPath = leadPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
Path = GraphicsHelper.GetGraphicsPath(BasePart.Program, BasePart.Location);
|
||||
Path.Transform(plateView.Matrix);
|
||||
LeadInPath?.Dispose();
|
||||
LeadInPath = null;
|
||||
}
|
||||
colorScheme = (plateView as PlateView)?.ColorScheme ?? ColorScheme.Default;
|
||||
RefreshColors();
|
||||
BasePart.Program.GetDisplayPaths(BasePart.Location,
|
||||
out var cutPath, out var leadPath, out var etchPath);
|
||||
cutPath.Transform(plateView.Matrix);
|
||||
leadPath.Transform(plateView.Matrix);
|
||||
etchPath.Transform(plateView.Matrix);
|
||||
Path?.Dispose();
|
||||
LeadInPath?.Dispose();
|
||||
EtchPath?.Dispose();
|
||||
Path = cutPath;
|
||||
EtchPath = etchPath;
|
||||
LeadInPath = BasePart.HasManualLeadIns ? leadPath : null;
|
||||
if (!BasePart.HasManualLeadIns)
|
||||
leadPath.Dispose();
|
||||
|
||||
var bounds = cutPath.GetBounds();
|
||||
if (etchPath.PointCount > 0)
|
||||
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
|
||||
// carries BaseDrawing.Program.Rotation (nonzero for canonical-frame drawings, e.g. in
|
||||
@@ -371,7 +386,8 @@ namespace OpenNest
|
||||
|
||||
public void Update()
|
||||
{
|
||||
Color = BasePart.BaseDrawing.Color;
|
||||
colorOverride = null;
|
||||
RefreshColors();
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-13
@@ -219,7 +219,9 @@ against owned clean material. Neither mode certifies final NC, production cuttin
|
||||
readiness or physical machine safety.
|
||||
|
||||
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
|
||||
modeled constraints. Locked internal crossings cannot be repaired by regeneration.
|
||||
@@ -293,8 +295,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
|
||||
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
|
||||
unlocked part's lead-ins are regenerated, and locked parts keep programs that must
|
||||
already pass the checks.
|
||||
unlocked part's lead-ins are regenerated. Locked parts keep their exact programs;
|
||||
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
|
||||
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.
|
||||
@@ -302,23 +304,28 @@ 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 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
|
||||
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.
|
||||
- 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
|
||||
an incomplete check (see [pre-post verification](post-verification.md)) block that plate
|
||||
whatever its route. A free-order search that ends
|
||||
material is checked for overlaps with the pre-post overlap analyzer; known overlapping parts
|
||||
still block that plate whatever its route. Incomplete checks remain visible warnings, never
|
||||
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
|
||||
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.
|
||||
- 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
|
||||
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
|
||||
refused plate may hold program graphs that are unsafe to copy, and a changed one would
|
||||
draw replayed programs at poses that were never checked.
|
||||
- Apply is enabled only when every plate is ready, and it is all or nothing through
|
||||
`CuttingPlanService.Apply`. After it applies, each plate keeps its own copy of the
|
||||
programs. Ready and best-effort proposals can be previewed only while the plate still
|
||||
matches capture. Best-effort previews are labelled `UNVERIFIED`. Refused program graphs
|
||||
are never copied, and changed plates require replanning.
|
||||
- Apply requires usable output for every plate and remains all or nothing. A ready batch
|
||||
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
|
||||
and asks for a replan; nothing changes.
|
||||
- Closing or cancelling while planning cancels the worker and keeps the dialog open until
|
||||
@@ -332,6 +339,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
|
||||
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
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
`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
|
||||
assertions extract with Poppler's `pdftotext -layout` and are skipped, not
|
||||
passed, when poppler-utils is absent. For manual inspection also use `pdfinfo`,
|
||||
assertions use Poppler's `pdftotext -bbox` coordinates to rebuild visual rows;
|
||||
`-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
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
+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
|
||||
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
|
||||
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;
|
||||
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`.
|
||||
@@ -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
|
||||
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
|
||||
`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
|
||||
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
|
||||
|
||||
@@ -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):
|
||||
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):
|
||||
workflow = pathlib.Path(__file__).resolve().parents[1] / ".github/workflows/server-image.yml"
|
||||
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