refactor: remove duplicated client, geometry and desktop code

Share the nest server client setup and page validation, entity endpoints,
post marker checks and the repeated desktop list, cut-time and busy-warning
code. Persistence tests use NestArchiveJson. No behavior change intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-31 22:13:26 -04:00
1 parent 31525ba342
commit 082a50bbcd
25 files changed
+249 -482

No files matched your search

+5 -26
View File
@@ -73,31 +73,10 @@ namespace OpenNest.CNC
/// Refuses a nest a post cannot write, before any output is created: a cut-off with a locked
/// overcut needs the post's lock and unlock codes, and its markers must be well formed.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCodes, string postName)
{
ArgumentNullException.ThrowIfNull(nest);
foreach (var plate in nest.Plates)
{
foreach (var part in plate.Parts)
{
if (part?.Program == null || !IsMarked(part.Program))
continue;
var name = DisplayPerimeterTail.PartName(part);
if (!hasCodes)
throw new InvalidOperationException(
$"{name} overcuts past the sheet edge with the head height locked, but the {postName} post "
+ "has no head-height lock and unlock codes. Enter them in the post's configuration, "
+ "or delete the cut-off and add it again with the head-height lock off.");
try
{
LockedMask(part.Program.Codes);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
}
}
}
}
public static void EnsurePostable(Nest nest, bool hasCodes, string postName) =>
PostMarkers.EnsurePostable(nest, hasCodes, IsMarked, program => LockedMask(program.Codes),
name => $"{name} overcuts past the sheet edge with the head height locked, but the {postName} post "
+ "has no head-height lock and unlock codes. Enter them in the post's configuration, "
+ "or delete the cut-off and add it again with the head-height lock off.");
}
}
+4 -25
View File
@@ -75,30 +75,9 @@ namespace OpenNest.CNC
/// Refuses a nest a post cannot write, before any output is created: a marked rapid needs
/// the post's head-raise code, and its markers must be well formed.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCode, string postName)
{
ArgumentNullException.ThrowIfNull(nest);
foreach (var plate in nest.Plates)
{
foreach (var part in plate.Parts)
{
if (part?.Program == null || !IsMarked(part.Program))
continue;
var name = DisplayPerimeterTail.PartName(part);
if (!hasCode)
throw new InvalidOperationException(
$"{name} has rapids that must raise the head over parts already cut, but the {postName} "
+ "post has no head-raise code. Enter it in the post's configuration before posting.");
try
{
RaisedMask(part.Program.Codes);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
}
}
}
}
public static void EnsurePostable(Nest nest, bool hasCode, string postName) =>
PostMarkers.EnsurePostable(nest, hasCode, IsMarked, program => RaisedMask(program.Codes),
name => $"{name} has rapids that must raise the head over parts already cut, but the {postName} "
+ "post has no head-raise code. Enter it in the post's configuration before posting.");
}
}
+37
View File
@@ -0,0 +1,37 @@
using System;
namespace OpenNest.CNC
{
internal static class PostMarkers
{
/// <summary>
/// Refuses a nest whose marked programs a post cannot write: every marked part needs the
/// post's <paramref name="hasCode"/>, and <paramref name="validate"/> rejects a malformed
/// program with an <see cref="ArgumentException"/>.
/// </summary>
public static void EnsurePostable(Nest nest, bool hasCode, Func<Program, bool> isMarked,
Action<Program> validate, Func<string, string> missingCode)
{
ArgumentNullException.ThrowIfNull(nest);
foreach (var plate in nest.Plates)
{
foreach (var part in plate.Parts)
{
if (part?.Program == null || !isMarked(part.Program))
continue;
var name = DisplayPerimeterTail.PartName(part);
if (!hasCode)
throw new InvalidOperationException(missingCode(name));
try
{
validate(part.Program);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"{name} cannot be posted: {ex.Message}", ex);
}
}
}
}
}
}
+1 -29
View File
@@ -158,35 +158,7 @@ namespace OpenNest.CNC
SetModeInc();
}
public virtual void Offset(Vector voffset)
{
var mode = Mode;
SetModeAbs();
for (int i = 0; i < Codes.Count; ++i)
{
var code = Codes[i];
if (code is SubProgramCall subpgm)
{
subpgm.Offset = new Geometry.Vector(
subpgm.Offset.X + voffset.X,
subpgm.Offset.Y + voffset.Y
);
}
if (code is Motion == false)
continue;
var code2 = (Motion)code;
code2.Offset(voffset);
}
if (mode == Mode.Incremental)
SetModeInc();
}
public virtual void Offset(Vector voffset) => Offset(voffset.X, voffset.Y);
public void LineTo(double x, double y)
{
+26
View File
@@ -0,0 +1,26 @@
namespace OpenNest.Geometry
{
internal static class EntityEndpoints
{
/// <summary>The start and end of a line or arc; false for any other entity.</summary>
public static bool TryGet(Entity entity, out Vector start, out Vector end)
{
switch (entity)
{
case Line line:
start = line.StartPoint;
end = line.EndPoint;
return true;
case Arc arc:
start = arc.StartPoint();
end = arc.EndPoint();
return true;
default:
start = end = default;
return false;
}
}
}
}
+15 -28
View File
@@ -555,13 +555,7 @@ public class GeometrySimplifier
if (start > 0)
{
var prev = entities[start - 1];
var prevEnd = prev switch
{
Line l => l.EndPoint,
Arc a => a.EndPoint(),
_ => Vector.Invalid,
};
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
if (EntityEndpoints.TryGet(prev, out _, out var prevEnd) && prevEnd.DistanceTo(points[0]) < 1e-6)
{
if (prev is Arc)
return new TangentEstimate(GetExitDirection(prev), true);
@@ -596,13 +590,7 @@ public class GeometrySimplifier
if (k + 1 < entities.Count)
{
var next = entities[k + 1];
var nextStart = next switch
{
Line l => l.StartPoint,
Arc a => a.StartPoint(),
_ => Vector.Invalid,
};
if (nextStart.IsValid() && nextStart.DistanceTo(points[^1]) < 1e-6)
if (EntityEndpoints.TryGet(next, out var nextStart, out _) && nextStart.DistanceTo(points[^1]) < 1e-6)
{
if (next is Arc nextArc)
return new TangentEstimate(GetEntryDirection(nextArc), true);
@@ -654,16 +642,20 @@ public class GeometrySimplifier
private static Vector GetEntryDirection(Entity entity) =>
entity switch
{
Line line => new Vector(
line.EndPoint.X - line.StartPoint.X,
line.EndPoint.Y - line.StartPoint.Y
),
Arc arc => arc.IsReversed
? new Vector(System.Math.Sin(arc.StartAngle), -System.Math.Cos(arc.StartAngle))
: new Vector(-System.Math.Sin(arc.StartAngle), System.Math.Cos(arc.StartAngle)),
Line line => LineDirection(line),
Arc arc => ArcTangent(arc, arc.StartAngle),
_ => Vector.Invalid,
};
private static Vector LineDirection(Line line) =>
new(line.EndPoint.X - line.StartPoint.X, line.EndPoint.Y - line.StartPoint.Y);
/// <summary>The direction of travel along an arc at <paramref name="angle"/>.</summary>
private static Vector ArcTangent(Arc arc, double angle) =>
arc.IsReversed
? new Vector(System.Math.Sin(angle), -System.Math.Cos(angle))
: new Vector(-System.Math.Sin(angle), System.Math.Cos(angle));
/// <summary>
/// Computes the tangent direction at the last point of a fitted arc,
/// used to chain tangent continuity to the next arc.
@@ -806,13 +798,8 @@ public class GeometrySimplifier
private static Vector GetExitDirection(Entity entity) =>
entity switch
{
Line line => new Vector(
line.EndPoint.X - line.StartPoint.X,
line.EndPoint.Y - line.StartPoint.Y
),
Arc arc => arc.IsReversed
? new Vector(System.Math.Sin(arc.EndAngle), -System.Math.Cos(arc.EndAngle))
: new Vector(-System.Math.Sin(arc.EndAngle), System.Math.Cos(arc.EndAngle)),
Line line => LineDirection(line),
Arc arc => ArcTangent(arc, arc.EndAngle),
_ => Vector.Invalid,
};
+3 -23
View File
@@ -585,8 +585,8 @@ namespace OpenNest.Geometry
}
if (
!TryGetEnds(last.Offset, out _, out var gapStart)
|| !TryGetEnds(next.Offset, out var gapEnd, out _)
!EntityEndpoints.TryGet(last.Offset, out _, out var gapStart)
|| !EntityEndpoints.TryGet(next.Offset, out var gapEnd, out _)
)
return;
@@ -625,7 +625,7 @@ namespace OpenNest.Geometry
corner = default;
if (
!TryGetEnds(last, out _, out corner)
!EntityEndpoints.TryGet(last, out _, out corner)
|| !TryGetTangents(last, out _, out var d1)
|| !TryGetTangents(next, out var d2, out _)
)
@@ -637,26 +637,6 @@ namespace OpenNest.Geometry
|| (side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
}
private static bool TryGetEnds(Entity entity, out Vector start, out Vector end)
{
switch (entity)
{
case Line line:
start = line.StartPoint;
end = line.EndPoint;
return true;
case Arc arc:
start = arc.StartPoint();
end = arc.EndPoint();
return true;
default:
start = end = default;
return false;
}
}
/// <summary>
/// Direction of travel at the start and end of a line or arc.
/// </summary>
+5 -23
View File
@@ -88,11 +88,11 @@ namespace OpenNest.Geometry
}
else
{
if (!TryEndpoints(entities[i], out var start, out _))
if (!EntityEndpoints.TryGet(entities[i], out var start, out _))
return Unresolved(entities);
var closed = false;
while (i < count && TryEndpoints(entities[i], out _, out var end))
while (i < count && EntityEndpoints.TryGet(entities[i], out _, out var end))
{
// A lone closed arc is a loop; a lone line cannot be, even when it
// has zero length and so ends where it starts.
@@ -105,7 +105,7 @@ namespace OpenNest.Geometry
if (
i + 1 >= count
|| !TryEndpoints(entities[i + 1], out var nextStart, out _)
|| !EntityEndpoints.TryGet(entities[i + 1], out var nextStart, out _)
|| !Near(nextStart, end)
)
break;
@@ -263,11 +263,11 @@ namespace OpenNest.Geometry
}
private bool StartsNear(int index, Vector point) =>
TryEndpoints(entities[index], out var start, out _)
EntityEndpoints.TryGet(entities[index], out var start, out _)
&& start.DistanceTo(point) <= JointTolerance;
private bool EndsNear(int index, Vector point) =>
TryEndpoints(entities[index], out _, out var end)
EntityEndpoints.TryGet(entities[index], out _, out var end)
&& end.DistanceTo(point) <= JointTolerance;
private bool InRun(int index, int first, int last)
@@ -360,24 +360,6 @@ namespace OpenNest.Geometry
}
}
private static bool TryEndpoints(Entity entity, out Vector start, out Vector end)
{
switch (entity)
{
case Line line:
start = line.pt1;
end = line.pt2;
return true;
case Arc arc:
start = arc.StartPoint();
end = arc.EndPoint();
return true;
default:
start = end = new Vector();
return false;
}
}
private static bool Near(Vector a, Vector b) => a.DistanceTo(b) <= ChainTolerance;
private static double SignedArea(List<Entity> entities, int first, int last)
+24
View File
@@ -0,0 +1,24 @@
namespace OpenNest.Data;
/// <summary>The connection every nest server client holds: its HTTP client and the server's base address.</summary>
public abstract class NestServerClient : IDisposable
{
private protected readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly Uri _baseUri;
private protected NestServerClient(HttpClient httpClient, string baseUrl, bool ownsClient)
{
_httpClient = httpClient;
_ownsClient = ownsClient;
_baseUri = ServerUrl.ParseBase(baseUrl, nameof(baseUrl));
}
private protected Uri Url(string relative) => new(_baseUri, relative);
public void Dispose()
{
if (_ownsClient)
_httpClient.Dispose();
}
}
+20
View File
@@ -21,6 +21,26 @@ internal static class NestServerHttp
public static void AddIfMatch(HttpRequestMessage request, int expectedRevision) =>
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
public static void ValidatePaging(int offset, int limit, int maxLimit)
{
if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
if (limit < 1 || limit > maxLimit) throw new ArgumentOutOfRangeException(nameof(limit));
}
public static string Paging(int offset, int limit) =>
$"offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}";
/// <summary>Whether a page is the one asked for and holds no more than its window and total allow.</summary>
public static bool IsBoundedPage(int count, int total, int pageOffset, int pageLimit, int offset, int limit) =>
count <= limit && pageOffset == offset && pageLimit == limit && total >= 0
&& count <= System.Math.Max(0L, (long)total - offset);
/// <summary>Whether a history page holds only revisions of <paramref name="id"/>, newest first.</summary>
public static bool IsRevisionHistory<T>(IReadOnlyList<T> items, Guid id, Func<T, Guid> idOf, Func<T, int> revisionOf)
where T : class =>
!items.Any(item => item is null || revisionOf(item) < 1 || idOf(item) != id)
&& !items.Zip(items.Skip(1)).Any(pair => revisionOf(pair.First) <= revisionOf(pair.Second));
/// <summary>Throws an <see cref="IOException"/> naming the action, status and the start of the body.</summary>
public static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken cancellationToken)
{
+7 -26
View File
@@ -16,27 +16,19 @@ namespace OpenNest.Data;
/// the library raises <see cref="ServerTooOldException"/>; this client never falls back
/// to another store. Other failures surface as <see cref="IOException"/>.
/// </summary>
public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
public sealed class RemoteDrawingLibrary : NestServerClient, IDrawingLibraryStore
{
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly Uri _baseUri;
public RemoteDrawingLibrary(string baseUrl)
: this(new HttpClient(), baseUrl)
: base(new HttpClient(), baseUrl, ownsClient: true)
{
_ownsClient = true;
}
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
public RemoteDrawingLibrary(HttpClient httpClient, string baseUrl)
: base(httpClient, baseUrl, ownsClient: false)
{
_httpClient = httpClient;
_baseUri = ServerUrl.ParseBase(baseUrl, nameof(baseUrl));
}
private Uri Url(string relative) => new(_baseUri, relative);
/// <summary>
/// Stores revision 1 under <c>record.Id</c>, which the caller allocates first so an
/// ambiguous failure can be reconciled by id. An existing id raises
@@ -106,11 +98,9 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default)
{
ValidateId(id);
if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
if (limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) throw new ArgumentOutOfRangeException(nameof(limit));
NestServerHttp.ValidatePaging(offset, limit, DrawingLibraryRules.MaxRevisionLimit);
using var response = await _httpClient.GetAsync(
Url($"api/drawings/{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"),
cancellationToken);
Url($"api/drawings/{id}/revisions?{NestServerHttp.Paging(offset, limit)}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
await ThrowIfServerTooOld(cancellationToken);
@@ -119,10 +109,8 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
await NestServerHttp.EnsureSuccessAsync(response, $"read library drawing history {id}", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<DrawingLibraryPage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty drawing history page.");
if (page.Items is null || page.Items.Count > limit || page.Offset != offset || page.Limit != limit
|| page.Total < 0 || page.Items.Count > System.Math.Max(0L, (long)page.Total - offset)
|| page.Items.Any(item => item is null || item.Revision < 1 || item.Id != id)
|| page.Items.Zip(page.Items.Skip(1)).Any(pair => pair.First.Revision <= pair.Second.Revision))
if (page.Items is null || !NestServerHttp.IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
|| !NestServerHttp.IsRevisionHistory(page.Items, id, item => item.Id, item => item.Revision))
throw new IOException("Nest server returned an invalid or unbounded drawing history page.");
return page;
}
@@ -293,13 +281,6 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable
var record = await response.Content.ReadFromJsonAsync<DrawingLibraryRecord>(NestServerHttp.JsonOptions, cancellationToken);
return record ?? throw new IOException("Nest server returned an empty library drawing record.");
}
public void Dispose()
{
if (_ownsClient)
_httpClient.Dispose();
}
}
/// <summary>The nest server predates the drawing library or another feature (it answered HTTP 404).</summary>
+9 -37
View File
@@ -15,7 +15,7 @@ namespace OpenNest.Data;
/// <see cref="SetReleaseRules"/> before sending. A server without sets raises
/// <see cref="ServerTooOldException"/>; other failures surface as <see cref="IOException"/>.
/// </summary>
public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
public sealed class RemoteDrawingSetStore : NestServerClient, IDrawingSetStore
{
private const string Feature = "drawing sets";
@@ -23,25 +23,17 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
private const string DrawingReleaseFeature = "releasing library drawings on their own";
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly Uri _baseUri;
public RemoteDrawingSetStore(string baseUrl)
: this(new HttpClient(), baseUrl)
: base(new HttpClient(), baseUrl, ownsClient: true)
{
_ownsClient = true;
}
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
public RemoteDrawingSetStore(HttpClient httpClient, string baseUrl)
: base(httpClient, baseUrl, ownsClient: false)
{
_httpClient = httpClient;
_baseUri = ServerUrl.ParseBase(baseUrl, nameof(baseUrl));
}
private Uri Url(string relative) => new(_baseUri, relative);
public async Task<DrawingSetPage> QueryAsync(DrawingSetQuery query, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(query);
@@ -206,8 +198,8 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
if (setId == Guid.Empty) throw new ArgumentException("A drawing set id is required.", nameof(setId));
if (nestId == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(nestId));
if (drawingId == Guid.Empty) throw new ArgumentException("A library drawing id is required.", nameof(drawingId));
ValidatePaging(offset, limit);
var query = Paging(offset, limit);
NestServerHttp.ValidatePaging(offset, limit, DrawingLibraryRules.MaxRevisionLimit);
var query = NestServerHttp.Paging(offset, limit);
if (setId is not null) query += $"&setId={setId}";
if (nestId is not null) query += $"&nestId={nestId}";
if (drawingId is not null) query += $"&drawingId={drawingId}";
@@ -220,7 +212,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
await NestServerHttp.EnsureSuccessAsync(response, "read releases", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<SetReleasePage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty release page.");
if (page.Items is null || !IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
if (page.Items is null || !NestServerHttp.IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
|| page.Items.Any(item => item is null || item.Sets is null || item.Drawings is null || item.Nests is null
|| (setId is not null && item.Sets.All(s => s?.SetId != setId))
|| (nestId is not null && item.Nests.All(n => n?.NestId != nestId))
@@ -327,8 +319,8 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
private async Task<DrawingSetPage?> GetPageAsync(string relative, int offset, int limit, string action,
CancellationToken cancellationToken)
{
ValidatePaging(offset, limit);
using var response = await _httpClient.GetAsync(Url($"{relative}?{Paging(offset, limit)}"), cancellationToken);
NestServerHttp.ValidatePaging(offset, limit, DrawingLibraryRules.MaxRevisionLimit);
using var response = await _httpClient.GetAsync(Url($"{relative}?{NestServerHttp.Paging(offset, limit)}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
await ThrowIfServerTooOld(cancellationToken);
@@ -337,7 +329,7 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
await NestServerHttp.EnsureSuccessAsync(response, action, cancellationToken);
var page = await response.Content.ReadFromJsonAsync<DrawingSetPage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty drawing set page.");
if (page.Items is null || !IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
if (page.Items is null || !NestServerHttp.IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
|| page.Items.Any(item => item is null))
throw new IOException("Nest server returned an invalid or unbounded drawing set page.");
return page;
@@ -359,10 +351,6 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
throw new ServerTooOldException(Feature);
}
private static bool IsBoundedPage(int count, int total, int pageOffset, int pageLimit, int offset, int limit) =>
count <= limit && pageOffset == offset && pageLimit == limit && total >= 0
&& count <= System.Math.Max(0L, (long)total - offset);
private static bool HasJoinedMembers(DrawingSetRecord record) =>
record.Members is not null
&& record.Members.All(m => m is not null && m.Drawing is not null && m.Drawing.Id == m.DrawingId);
@@ -432,15 +420,6 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
if (revision < 1) throw new ArgumentOutOfRangeException(nameof(revision));
}
private static void ValidatePaging(int offset, int limit)
{
if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
if (limit < 1 || limit > DrawingLibraryRules.MaxRevisionLimit) throw new ArgumentOutOfRangeException(nameof(limit));
}
private static string Paging(int offset, int limit) =>
$"offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}";
private static string BuildQueryString(DrawingSetQuery query)
{
var text = new StringBuilder()
@@ -469,13 +448,6 @@ public sealed class RemoteDrawingSetStore : IDrawingSetStore, IDisposable
throw new IOException("Nest server returned an empty set release record.");
return release;
}
public void Dispose()
{
if (_ownsClient)
_httpClient.Dispose();
}
}
/// <summary>A guarded set write found a newer or different current state; nothing was changed.</summary>
+7 -25
View File
@@ -14,28 +14,19 @@ namespace OpenNest.Data;
/// a "metadata" JSON part and a "file" part holding the .nest archive.
/// Connection and HTTP failures surface as HttpRequestException/IOException to the caller.
/// </summary>
public sealed class RemoteNestRepository : INestRepository, IDisposable
public sealed class RemoteNestRepository : NestServerClient, INestRepository
{
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly Uri _baseUri;
public RemoteNestRepository(string baseUrl)
: this(new HttpClient(), baseUrl)
: base(new HttpClient(), baseUrl, ownsClient: true)
{
_ownsClient = true;
}
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
public RemoteNestRepository(HttpClient httpClient, string baseUrl)
: base(httpClient, baseUrl, ownsClient: false)
{
_httpClient = httpClient;
_ownsClient = false;
_baseUri = ServerUrl.ParseBase(baseUrl, nameof(baseUrl));
}
private Uri Url(string relative) => new(_baseUri, relative);
public async Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default)
{
using var response = await _httpClient.GetAsync(Url("api/nests"), cancellationToken);
@@ -192,19 +183,16 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
CancellationToken cancellationToken)
{
ValidateId(id);
if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
if (limit < 1 || limit > NestRevisionPage.MaxLimit) throw new ArgumentOutOfRangeException(nameof(limit));
NestServerHttp.ValidatePaging(offset, limit, NestRevisionPage.MaxLimit);
using var response = await _httpClient.GetAsync(
Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), cancellationToken);
Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions?{NestServerHttp.Paging(offset, limit)}"), cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
throw new IOException("Nest history is unavailable or this nest no longer exists. Upgrade the nest server if it does not support version history, then reload.");
await NestServerHttp.EnsureSuccessAsync(response, $"read nest history {id}", cancellationToken);
var page = await response.Content.ReadFromJsonAsync<NestRevisionPage>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned an empty history page.");
if (page.Items is null || page.Items.Count > limit || page.Offset != offset || page.Limit != limit
|| page.Total < 0 || page.Items.Count > System.Math.Max(0L, (long)page.Total - offset)
|| page.Items.Any(item => item is null || item.Revision < 1 || item.Id != id)
|| page.Items.Zip(page.Items.Skip(1)).Any(pair => pair.First.Revision <= pair.Second.Revision))
if (page.Items is null || !NestServerHttp.IsBoundedPage(page.Items.Count, page.Total, page.Offset, page.Limit, offset, limit)
|| !NestServerHttp.IsRevisionHistory(page.Items, id, item => item.Id, item => item.Revision))
throw new IOException("Nest server returned an invalid or unbounded history page.");
return page;
}
@@ -308,12 +296,6 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
if (revisionNumber < 1) throw new ArgumentOutOfRangeException(nameof(revisionNumber));
}
public void Dispose()
{
if (_ownsClient)
_httpClient.Dispose();
}
private static MultipartFormDataContent BuildMultipart(byte[] nestFile, NestRecord record)
{
var content = new MultipartFormDataContent();
+3 -17
View File
@@ -8,30 +8,22 @@ namespace OpenNest.Data;
/// A server without them raises <see cref="ServerTooOldException"/>; a stale write raises
/// <see cref="SharedSettingSaveConflictException"/>; other failures surface as <see cref="IOException"/>.
/// </summary>
public sealed class RemoteSharedSettings : IDisposable
public sealed class RemoteSharedSettings : NestServerClient
{
private const string Feature = "shared machine settings";
private readonly HttpClient _httpClient;
private readonly bool _ownsClient;
private readonly Uri _baseUri;
/// <summary>A client that gives up after <paramref name="timeout"/>, so an unreachable server falls back quickly.</summary>
public RemoteSharedSettings(string baseUrl, TimeSpan timeout)
: this(new HttpClient { Timeout = timeout }, baseUrl)
: base(new HttpClient { Timeout = timeout }, baseUrl, ownsClient: true)
{
_ownsClient = true;
}
/// <summary>For tests and hosts that pool HttpClient instances.</summary>
public RemoteSharedSettings(HttpClient httpClient, string baseUrl)
: base(httpClient, baseUrl, ownsClient: false)
{
_httpClient = httpClient;
_baseUri = ServerUrl.ParseBase(baseUrl, nameof(baseUrl));
}
private Uri Url(string relative) => new(_baseUri, relative);
private static string Path(string kind, string key) =>
$"api/settings/{Uri.EscapeDataString(kind)}/{Uri.EscapeDataString(key)}";
@@ -122,12 +114,6 @@ public sealed class RemoteSharedSettings : IDisposable
private static async Task<SharedSettingRecord> ReadRecord(HttpResponseMessage response, CancellationToken cancellationToken) =>
await response.Content.ReadFromJsonAsync<SharedSettingRecord>(NestServerHttp.JsonOptions, cancellationToken)
?? throw new IOException("Nest server returned empty settings.");
public void Dispose()
{
if (_ownsClient)
_httpClient.Dispose();
}
}
/// <summary>A shared settings write found a newer or different stored document; nothing was changed.</summary>
@@ -2,6 +2,7 @@ using System.IO.Compression;
using System.Text;
using System.Text.Json.Nodes;
using OpenNest.Geometry;
using static OpenNest.IO.Tests.NestArchiveJson;
namespace OpenNest.IO.Tests;
@@ -29,14 +30,6 @@ public class ClampEdgePersistenceTests
return nest;
}
private static MemoryStream Write(Nest nest)
{
var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
return stream;
}
private static JsonNode RailJson(MemoryStream zip)
{
zip.Position = 0;
@@ -32,14 +32,6 @@ public class ClampRailPersistenceTests
return nest;
}
private static MemoryStream Write(Nest nest)
{
var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
return stream;
}
private static Nest RoundTrip(Nest nest) => new NestReader(Write(nest)).Read();
[Fact]
+24
View File
@@ -16,6 +16,30 @@ internal static class NestArchiveJson
return (reader.Read(), reader.Warnings);
}
public static MemoryStream Write(Nest nest)
{
var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
return stream;
}
/// <summary>The archive's nest.json, leaving <paramref name="zip"/> ready to read again.</summary>
public static JsonObject NestJson(MemoryStream zip)
{
var json = NestJson(zip.ToArray());
zip.Position = 0;
return json;
}
/// <summary>A copy of the archive whose nest.json was changed by <paramref name="edit"/>.</summary>
public static MemoryStream Rewrite(MemoryStream zip, Action<JsonObject> edit)
{
var json = NestJson(zip);
edit(json);
return new MemoryStream(ReplaceNestJson(zip.ToArray(), json));
}
public static JsonObject NestJson(byte[] archive)
{
using var zip = new ZipArchive(new MemoryStream(archive), ZipArchiveMode.Read);
@@ -1,7 +1,6 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json.Nodes;
using OpenNest.Geometry;
using static OpenNest.IO.Tests.NestArchiveJson;
namespace OpenNest.IO.Tests;
@@ -21,41 +20,6 @@ public class NestMachinePersistenceTests
return nest;
}
private static MemoryStream Write(Nest nest)
{
var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
return stream;
}
private static JsonNode NestJson(MemoryStream zip)
{
zip.Position = 0;
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
using var reader = new StreamReader(archive.GetEntry("nest.json")!.Open(), Encoding.UTF8);
var json = JsonNode.Parse(reader.ReadToEnd())!;
zip.Position = 0;
return json;
}
private static MemoryStream Rewrite(MemoryStream zip, Action<JsonNode> edit)
{
var json = NestJson(zip);
edit(json);
var copy = new MemoryStream();
zip.Position = 0;
zip.CopyTo(copy);
using (var archive = new ZipArchive(copy, ZipArchiveMode.Update, leaveOpen: true))
{
archive.GetEntry("nest.json")!.Delete();
using var writer = new StreamWriter(archive.CreateEntry("nest.json").Open(), new UTF8Encoding(false));
writer.Write(json.ToJsonString());
}
copy.Position = 0;
return copy;
}
[Fact]
public void MachineRoundTrips()
{
@@ -1,9 +1,8 @@
using System.IO.Compression;
using System.Text;
using System.Text.Json.Nodes;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
using OpenNest.Support;
using static OpenNest.IO.Tests.NestArchiveJson;
namespace OpenNest.IO.Tests;
@@ -25,41 +24,6 @@ public class SupportGridPersistenceTests
return nest;
}
private static MemoryStream Write(Nest nest)
{
var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
return stream;
}
private static JsonNode NestJson(MemoryStream zip)
{
zip.Position = 0;
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
using var reader = new StreamReader(archive.GetEntry("nest.json")!.Open(), Encoding.UTF8);
var json = JsonNode.Parse(reader.ReadToEnd())!;
zip.Position = 0;
return json;
}
private static MemoryStream Rewrite(MemoryStream zip, Action<JsonNode> edit)
{
var json = NestJson(zip);
edit(json);
var copy = new MemoryStream();
zip.Position = 0;
zip.CopyTo(copy);
using (var archive = new ZipArchive(copy, ZipArchiveMode.Update, leaveOpen: true))
{
archive.GetEntry("nest.json")!.Delete();
using var writer = new StreamWriter(archive.CreateEntry("nest.json").Open(), new UTF8Encoding(false));
writer.Write(json.ToJsonString());
}
copy.Position = 0;
return copy;
}
[Fact]
public void GridRoundTripsAndEveryPlateFollowsIt()
{
+19 -53
View File
@@ -559,27 +559,8 @@ namespace OpenNest.Controls
}
/// <summary>Shows one line per breakoff with <paramref name="selected"/> selected, without raising selection events.</summary>
public void LoadBreakoffs(IReadOnlyList<string> labels, int selected)
{
var added = labels.Count > breakoffsList.Items.Count;
loadingBreakoffs = true;
try
{
breakoffsList.BeginUpdate();
breakoffsList.Items.Clear();
foreach (var label in labels)
breakoffsList.Items.Add(label);
breakoffsList.SelectedIndex = selected >= 0 && selected < labels.Count ? selected : -1;
breakoffsList.EndUpdate();
}
finally
{
loadingBreakoffs = false;
}
breakoffsPanel.HeaderText = $"Breakoffs ({labels.Count})";
if (added)
breakoffsPanel.IsExpanded = true;
}
public void LoadBreakoffs(IReadOnlyList<string> labels, int selected) =>
LoadList(breakoffsList, breakoffsPanel, "Breakoffs", ref loadingBreakoffs, labels, selected);
public void SetTabbedCutoutPickMode(bool active)
{
@@ -588,49 +569,34 @@ namespace OpenNest.Controls
}
/// <summary>Shows one line per tabbed cutout with <paramref name="selected"/> selected, without raising selection events.</summary>
public void LoadTabbedCutouts(IReadOnlyList<string> labels, int selected)
{
var added = labels.Count > cutoutsList.Items.Count;
loadingCutouts = true;
try
{
cutoutsList.BeginUpdate();
cutoutsList.Items.Clear();
foreach (var label in labels)
cutoutsList.Items.Add(label);
cutoutsList.SelectedIndex = selected >= 0 && selected < labels.Count ? selected : -1;
cutoutsList.EndUpdate();
}
finally
{
loadingCutouts = false;
}
cutoutsPanel.HeaderText = $"Tabbed Cutouts ({labels.Count})";
if (added)
cutoutsPanel.IsExpanded = true;
}
public void LoadTabbedCutouts(IReadOnlyList<string> labels, int selected) =>
LoadList(cutoutsList, cutoutsPanel, "Tabbed Cutouts", ref loadingCutouts, labels, selected);
/// <summary>Shows one line per dimension with <paramref name="selected"/> selected, without raising selection events.</summary>
public void LoadDimensions(IReadOnlyList<string> labels, int selected)
public void LoadDimensions(IReadOnlyList<string> labels, int selected) =>
LoadList(dimensionsList, dimensionsPanel, "Dimensions", ref loadingDimensions, labels, selected);
private static void LoadList(ListBox list, CollapsiblePanel panel, string title, ref bool loading,
IReadOnlyList<string> labels, int selected)
{
var added = labels.Count > dimensionsList.Items.Count;
loadingDimensions = true;
var added = labels.Count > list.Items.Count;
loading = true;
try
{
dimensionsList.BeginUpdate();
dimensionsList.Items.Clear();
list.BeginUpdate();
list.Items.Clear();
foreach (var label in labels)
dimensionsList.Items.Add(label);
dimensionsList.SelectedIndex = selected >= 0 && selected < labels.Count ? selected : -1;
dimensionsList.EndUpdate();
list.Items.Add(label);
list.SelectedIndex = selected >= 0 && selected < labels.Count ? selected : -1;
list.EndUpdate();
}
finally
{
loadingDimensions = false;
loading = false;
}
dimensionsPanel.HeaderText = $"Dimensions ({labels.Count})";
panel.HeaderText = $"{title} ({labels.Count})";
if (added)
dimensionsPanel.IsExpanded = true;
panel.IsExpanded = true;
}
}
+9 -27
View File
@@ -1110,7 +1110,13 @@ namespace OpenNest.Forms
PlateSequencing.Apply(plate, parameters);
}
public void CalculateCurrentPlateCutTime()
public void CalculateCurrentPlateCutTime() =>
ShowCutTime(() => Timing.GetTimingInfo(PlateView.Plate), cutparams => Timing.CalculateTime(PlateView.Plate, cutparams));
public void CalculateNestCutTime() =>
ShowCutTime(() => Timing.GetTimingInfo(Nest), cutparams => Timing.CalculateTime(Nest, cutparams));
private void ShowCutTime(Func<TimingInfo> getInfo, Func<CutParameters, TimeSpan> calculateTime)
{
var cutParamsForm = new CutParametersForm();
cutParamsForm.Units = Nest.Units;
@@ -1118,8 +1124,8 @@ namespace OpenNest.Forms
if (cutParamsForm.ShowDialog() == DialogResult.OK)
{
var cutparams = cutParamsForm.GetCutParameters();
var info = Timing.GetTimingInfo(PlateView.Plate);
var time = Timing.CalculateTime(PlateView.Plate, cutparams);
var info = getInfo();
var time = calculateTime(cutparams);
var timingForm = new TimingForm();
timingForm.Units = Nest.Units;
@@ -1155,30 +1161,6 @@ namespace OpenNest.Forms
System.Math.Max(owner.Top, corner.Y - cutSimulatorForm.Height - 48));
}
public void CalculateNestCutTime()
{
var cutParamsForm = new CutParametersForm();
cutParamsForm.Units = Nest.Units;
if (cutParamsForm.ShowDialog() == DialogResult.OK)
{
var cutparams = cutParamsForm.GetCutParameters();
var info = Timing.GetTimingInfo(Nest);
var time = Timing.CalculateTime(Nest, cutparams);
var timingForm = new TimingForm();
timingForm.Units = Nest.Units;
timingForm.SetCutDistance(info.CutDistance);
timingForm.SetCutTime(time);
timingForm.SetIntersectionCount(info.IntersectionCount);
timingForm.SetPierceCount(info.PierceCount);
timingForm.SetRapidDistance(info.TravelDistance);
timingForm.SetCutParameters(cutparams);
timingForm.ShowDialog();
}
}
private void FirePlateChanged(bool updateListView = true)
{
if (updateListView)
+10
View File
@@ -29,6 +29,16 @@ namespace OpenNest.Forms
IsJobRunning
|| views.Any(v => v.IsFillInProgress || v.Actions.CurrentAction?.IsBusy() == true);
/// <summary>Tells the user to finish first when <see cref="IsPlateWorkBusy"/>; true if it was.</summary>
private bool WarnIfPlateWorkBusy(PlateView[] views, string caption)
{
if (!IsPlateWorkBusy(views))
return false;
MessageBox.Show(this, "Finish or cancel the current nesting or plate action first.",
caption, MessageBoxButtons.OK, MessageBoxIcon.Information);
return true;
}
/// <summary>
/// A running job or a fill on any view of <paramref name="nest"/>; a busy current
/// action does not count.
+3 -20
View File
@@ -90,12 +90,8 @@ namespace OpenNest.Forms
var nest = editForm.Nest;
var views = ViewsOf(nest);
if (IsPlateWorkBusy(views))
{
MessageBox.Show(this, "Finish or cancel the current nesting or plate action first.",
"Machine", MessageBoxButtons.OK, MessageBoxIcon.Information);
if (WarnIfPlateWorkBusy(views, "Machine"))
return;
}
editForm.RecordChange("Machine", () =>
{
@@ -125,17 +121,8 @@ namespace OpenNest.Forms
var nest = editForm.Nest;
var views = ViewsOf(nest);
if (IsPlateWorkBusy(views))
{
MessageBox.Show(
this,
"Finish or cancel the current nesting or plate action first.",
"Clamps",
MessageBoxButtons.OK,
MessageBoxIcon.Information
);
if (WarnIfPlateWorkBusy(views, "Clamps"))
return;
}
var machine = MachineSettings.FindMachine(nest.Machine);
Action<ClampSettings> save = machine == null
@@ -160,12 +147,8 @@ namespace OpenNest.Forms
var nest = editForm.Nest;
var views = ViewsOf(nest);
if (IsPlateWorkBusy(views))
{
MessageBox.Show(this, "Finish or cancel the current nesting or plate action first.",
"Support Bed", MessageBoxButtons.OK, MessageBoxIcon.Information);
if (WarnIfPlateWorkBusy(views, "Support Bed"))
return;
}
var machine = MachineSettings.FindMachine(nest.Machine);
Action<SupportGridSettings> save = machine == null
+1 -5
View File
@@ -86,12 +86,8 @@ namespace OpenNest.Forms
var nest = editForm.Nest;
var views = ViewsOf(nest);
bool IsBusy() => IsPlateWorkBusy(views);
if (IsBusy())
{
MessageBox.Show(this, "Finish or cancel the current nesting or plate action first.",
"Automatic Scrap Cutoffs", MessageBoxButtons.OK, MessageBoxIcon.Information);
if (WarnIfPlateWorkBusy(views, "Automatic Scrap Cutoffs"))
return;
}
try
{
+14 -28
View File
@@ -43,48 +43,34 @@ namespace OpenNest.Forms
/// URL changes. Throws <see cref="ArgumentException"/> if the configured URL
/// is not a valid absolute http/https address.
/// </summary>
public static INestRepository CreateRepository()
{
if (cachedRepository == null || cachedServerUrl != settings.ServerUrl)
{
cachedRepository?.Dispose();
cachedRepository = new RemoteNestRepository(settings.ServerUrl);
cachedServerUrl = settings.ServerUrl;
}
return cachedRepository;
}
public static INestRepository CreateRepository() =>
GetOrCreate(ref cachedRepository, ref cachedServerUrl, url => new RemoteNestRepository(url));
/// <summary>
/// The shared drawing library client for the current server URL, recreated only when
/// the URL changes. Throws <see cref="ArgumentException"/> for an invalid URL.
/// </summary>
public static IDrawingLibraryStore CreateDrawingLibrary()
{
if (cachedLibrary == null || cachedLibraryUrl != settings.ServerUrl)
{
cachedLibrary?.Dispose();
cachedLibrary = new RemoteDrawingLibrary(settings.ServerUrl);
cachedLibraryUrl = settings.ServerUrl;
}
return cachedLibrary;
}
public static IDrawingLibraryStore CreateDrawingLibrary() =>
GetOrCreate(ref cachedLibrary, ref cachedLibraryUrl, url => new RemoteDrawingLibrary(url));
/// <summary>
/// The shared drawing set client for the current server URL, recreated only when the
/// URL changes. Throws <see cref="ArgumentException"/> for an invalid URL.
/// </summary>
public static IDrawingSetStore CreateDrawingSetStore()
public static IDrawingSetStore CreateDrawingSetStore() =>
GetOrCreate(ref cachedSets, ref cachedSetsUrl, url => new RemoteDrawingSetStore(url));
private static T GetOrCreate<T>(ref T cached, ref string cachedUrl, Func<string, T> create)
where T : NestServerClient
{
if (cachedSets == null || cachedSetsUrl != settings.ServerUrl)
if (cached == null || cachedUrl != settings.ServerUrl)
{
cachedSets?.Dispose();
cachedSets = new RemoteDrawingSetStore(settings.ServerUrl);
cachedSetsUrl = settings.ServerUrl;
cached?.Dispose();
cached = create(settings.ServerUrl);
cachedUrl = settings.ServerUrl;
}
return cachedSets;
return cached;
}
}
}