Author SHA1 Message Date
aj eff148c24e fix(test): unpool direct transient server databases 2026-10-08 20:03:10 -04:00
aj 648ffbca65 fix(test): own and close private server database before cleanup 2026-10-08 19:55:56 -04:00
aj 4d94273a00 fix(test): release transient SQLite handles for Windows server tests 2026-10-08 19:49:09 -04:00
aj 028adee046 ci(release): pin Poppler for Windows PDF verification 2026-10-08 19:42:13 -04:00
aj 3ddfff242d fix(test): read PDF text by visual rows across platforms 2026-10-08 19:36:33 -04:00
aj 5fc563bbca test(report): retain Windows PDF extraction with diagnostic 2026-10-08 19:30:15 -04:00
aj 785a861ab9 test(report): place diagnostic PDF in CI artifacts 2026-10-08 19:26:31 -04:00
aj 986ba3dee1 test(report): retain Windows PDF for failed release diagnosis 2026-10-08 19:22:25 -04:00
aj a73a85ebc0 ci(server): skip image publication for GitHub prereleases 2026-10-08 19:17:15 -04:00
aj 8628c69098 fix(ui): settle shift-click cloned parts with repeated directional pushes 2026-10-08 18:57:39 -04:00
aj c1c4b18f17 fix(ui): keep closed etch-only parts selectable 2026-10-08 13:50:12 -04:00
aj 7e887921ea fix(cutting): expose every unverified warning before consent 2026-10-08 13:50:12 -04:00
aj f50705c491 merge: integrate golden-angle Workshop colors 2026-10-08 13:34:23 -04:00
aj 81f3c006ab merge: integrate best-effort cutting on current master 2026-10-08 13:34:05 -04:00
aj b83518feac merge: integrate reviewed cutting-start planner fixes (#10)
Preserve reviewed cutting-start and plate-label commits; operator accepted A1-A4 and B1-B7. Excludes best-effort cutting and color trials.
2026-10-08 11:01:21 -04:00
aj 1826e6d5b5 feat(ui): add Workshop palette with distinct drawing colors
Keep cut outlines dark and etch strokes bright green. Assign drawing fills by a golden-angle hue walk with varied saturation/lightness tiers, preserving legacy schemes and rendering-only behavior. Include palette, visibility, persistence, and contrast regressions.
2026-10-08 10:24:11 -04:00
ajandClaude Sonnet 5 47140cc59e perf(ui): prefetch nearby nests in the Database Open dialog
Browsing the saved-nest list was slow because arrowing to each row
downloaded that nest's archive from scratch. NestDetailsSession now
keeps a small LRU cache and exposes Prefetch(id); SavedNestsForm warms
the rows around the highlight once its own details are showing, so
arrowing through the page mostly resolves from cache instead of
re-downloading each nest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-07 13:13:25 -04:00
aj 91aef95b1b fix(tests): acknowledge native BOM dialog without assuming button id
Fix DuplicateRows_CreateNestsButtonImportsOneDrawingWithCombinedQuantity timeout from Windows run 37526243901. Native trace on DesktopPC showed the sole OK button uses control ID 2, not IDOK 1. Preserve real dialog text and nest assertions; find the sole button and stop polling only after successful posting. All 392 WinForms and 49 FrontEnd tests pass in an interactive Windows session.
2026-10-06 16:52:52 -04:00
aj 9110cd62e0 fix(ci): retain readable results from unsuccessful Windows runs 2026-10-06 16:11:32 -04:00
aj 8e8b1de25f fix(ci): reject contradictory Windows test counters 2026-10-06 16:09:26 -04:00
aj 1add37d3e0 test(windows): require shown-form acceptance lifecycle cases 2026-10-06 16:00:48 -04:00
aj 71f41e23e4 test(ui): cover shown BOM import and nest info persistence 2026-10-06 15:59:57 -04:00
aj b591528aa2 ci(windows): retain fail-closed desktop acceptance evidence 2026-10-06 15:56:27 -04:00
aj 12206ed8fd feat(cutting): offer unverified best-effort plans for imperfect geometry 2026-10-06 14:44:53 -04:00
54 changed files with 3807 additions and 186 deletions

No files matched your search

+5
View File
@@ -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
+1 -1
View File
@@ -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
+14
View File
@@ -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
+10 -5
View File
@@ -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()) { }
+57
View File
@@ -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));
}
}
}
+81 -3
View File
@@ -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.
+57
View File
@@ -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(() =>
{
+4 -4
View File
@@ -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);
}
+7 -2
View File
@@ -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
{
+66
View File
@@ -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()
{
+49
View File
@@ -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));
}
+22 -8
View File
@@ -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)))
+262
View File
@@ -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.");
}
+2 -6
View File
@@ -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
View File
@@ -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",
+28 -3
View File
@@ -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
{
+12
View File
@@ -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; }
}
}
}
+4 -5
View File
@@ -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();
+3 -5
View File
@@ -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);
}
+2 -1
View File
@@ -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;
+7 -5
View File
@@ -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
View File
@@ -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;
+19 -4
View File
@@ -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)
{
+3 -2
View File
@@ -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();
+9 -4
View File
@@ -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(
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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.
+6 -2
View File
@@ -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.
+67
View File
@@ -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
View File
@@ -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
+85
View File
@@ -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.
+7
View File
@@ -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()
+198
View File
@@ -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
+204
View File
@@ -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())