mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-04 08:01:35 -05:00
A server Auto Nest result could only be reviewed in the session that sent it, and nothing stopped two desktops from keeping the same result. Phase 3 adds My Server Jobs, so results that finished while OpenNest was closed are easy to find, and records each review on the server. - Server: GET /api/nest-jobs lists jobs newest first with state, engine, submitter, times, queue position, review and the worker's summary (parts, sheets, utilization, unplaced). It filters to active, finished or unreviewed jobs and to a submitter (a bare user name matches that user on any workstation). An ETag turns unchanged polls into 304s. Schema v17 adds nestId, review and summary columns, only where missing. - Keep once: POST .../keep and .../discard take If-Match on the job revision and change an unreviewed result only once. A second desktop gets 409 with who kept it. DELETE removes finished jobs. A succeeded job keeps its package until it is reviewed, so a later session can rebuild the job. - Retention: OPENNEST_JOB_RETENTION_DAYS (default 7) deletes finished jobs with their results. It runs on claims and list reads. A malformed value fails startup instead of deleting results early. - Worker: reports the result's summary with the transcript. A job cancelled while its result arrived keeps no summary (found in the Docker smoke run). - Desktop: Nest > My Server Jobs polls the list every 3 s; polling rather than per-job SSE because the list must show other desktops' jobs and removals. Open rebuilds the job from job.json against the open nest's own drawings. New per-drawing geometry fingerprints make the freshness check work across sessions. Cancel Job and Remove act on the selected job. A status-bar item counts this user's results waiting for review; there is no popup. Auto Nest's review now records Keep on the server before it commits, and records Discard too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
6.1 KiB
C#
172 lines
6.1 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using OpenNest.CNC;
|
|
|
|
namespace OpenNest.Engine.Jobs;
|
|
|
|
/// <summary>Exact immutable CNC motion values. Rapid moves retain contour/hole boundaries; arcs are not tessellated.</summary>
|
|
public sealed record PartGeometryMotion(
|
|
CodeType Type,
|
|
double X,
|
|
double Y,
|
|
double CenterX,
|
|
double CenterY,
|
|
RotationType Rotation,
|
|
LayerType Layer,
|
|
bool Suppressed
|
|
);
|
|
|
|
/// <summary>
|
|
/// Owned geometry only: no Drawing, quantity, events, or mutable CNC references are retained.
|
|
/// This initial boundary supports flat rapid/linear/arc programs and rejects other instructions explicitly.
|
|
/// Coordinates and mode are preserved without normalization, rounding, or polygon approximation.
|
|
/// </summary>
|
|
public sealed class PartGeometrySnapshot
|
|
{
|
|
private PartGeometrySnapshot(Mode mode, IEnumerable<PartGeometryMotion> motions)
|
|
{
|
|
Mode = mode;
|
|
Motions = NestJob.Own(motions);
|
|
}
|
|
|
|
private int? contentHash;
|
|
|
|
public Mode Mode { get; }
|
|
public IReadOnlyList<PartGeometryMotion> Motions { get; }
|
|
|
|
/// <summary>
|
|
/// Equality by exact content: the same mode and the same motions in the same order, every
|
|
/// value bit for bit (so 0.0 and -0.0 differ), whatever the instances. Snapshots themselves
|
|
/// still compare by reference, as job identity does; this comparer is for caches whose
|
|
/// results depend on the geometry alone, such as best-fit candidates.
|
|
/// </summary>
|
|
public static IEqualityComparer<PartGeometrySnapshot> ContentComparer { get; } = new ContentEquality();
|
|
|
|
private int ContentHash
|
|
{
|
|
get
|
|
{
|
|
if (contentHash is int cached)
|
|
return cached;
|
|
var hash = new HashCode();
|
|
hash.Add(Mode);
|
|
foreach (var motion in Motions)
|
|
{
|
|
hash.Add(motion.Type);
|
|
hash.Add(Bits(motion.X));
|
|
hash.Add(Bits(motion.Y));
|
|
hash.Add(Bits(motion.CenterX));
|
|
hash.Add(Bits(motion.CenterY));
|
|
hash.Add(motion.Rotation);
|
|
hash.Add(motion.Layer);
|
|
hash.Add(motion.Suppressed);
|
|
}
|
|
var value = hash.ToHashCode();
|
|
contentHash = value;
|
|
return value;
|
|
}
|
|
}
|
|
|
|
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
|
|
|
/// <summary>
|
|
/// A SHA-256 of the exact content <see cref="ContentComparer"/> compares (lowercase hex):
|
|
/// equal geometry gives equal fingerprints across processes and machines, so geometry can be
|
|
/// checked against a copy that no longer exists (a server job reviewed in a later session).
|
|
/// </summary>
|
|
public string Fingerprint()
|
|
{
|
|
using var buffer = new System.IO.MemoryStream();
|
|
using (var writer = new System.IO.BinaryWriter(buffer, System.Text.Encoding.UTF8, leaveOpen: true))
|
|
{
|
|
writer.Write((int)Mode);
|
|
writer.Write(Motions.Count);
|
|
foreach (var motion in Motions)
|
|
{
|
|
writer.Write((int)motion.Type);
|
|
writer.Write(Bits(motion.X));
|
|
writer.Write(Bits(motion.Y));
|
|
writer.Write(Bits(motion.CenterX));
|
|
writer.Write(Bits(motion.CenterY));
|
|
writer.Write((int)motion.Rotation);
|
|
writer.Write((int)motion.Layer);
|
|
writer.Write(motion.Suppressed);
|
|
}
|
|
}
|
|
return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(buffer.ToArray())).ToLowerInvariant();
|
|
}
|
|
|
|
private static bool SameMotion(PartGeometryMotion a, PartGeometryMotion b) =>
|
|
a.Type == b.Type
|
|
&& Bits(a.X) == Bits(b.X)
|
|
&& Bits(a.Y) == Bits(b.Y)
|
|
&& Bits(a.CenterX) == Bits(b.CenterX)
|
|
&& Bits(a.CenterY) == Bits(b.CenterY)
|
|
&& a.Rotation == b.Rotation
|
|
&& a.Layer == b.Layer
|
|
&& a.Suppressed == b.Suppressed;
|
|
|
|
private sealed class ContentEquality : IEqualityComparer<PartGeometrySnapshot>
|
|
{
|
|
public bool Equals(PartGeometrySnapshot x, PartGeometrySnapshot y)
|
|
{
|
|
if (ReferenceEquals(x, y))
|
|
return true;
|
|
if (x is null || y is null || x.Mode != y.Mode || x.Motions.Count != y.Motions.Count)
|
|
return false;
|
|
for (var i = 0; i < x.Motions.Count; i++)
|
|
if (!SameMotion(x.Motions[i], y.Motions[i]))
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
public int GetHashCode(PartGeometrySnapshot snapshot) => snapshot.ContentHash;
|
|
}
|
|
|
|
/// <summary>Copies supported motion geometry immediately; later program edits cannot affect this snapshot.</summary>
|
|
public static PartGeometrySnapshot FromProgram(Program program)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(program);
|
|
var motions = program.Codes.Select(code =>
|
|
code switch
|
|
{
|
|
ArcMove arc => new PartGeometryMotion(
|
|
arc.Type,
|
|
arc.EndPoint.X,
|
|
arc.EndPoint.Y,
|
|
arc.CenterPoint.X,
|
|
arc.CenterPoint.Y,
|
|
arc.Rotation,
|
|
arc.Layer,
|
|
arc.Suppressed
|
|
),
|
|
LinearMove line => new PartGeometryMotion(
|
|
line.Type,
|
|
line.EndPoint.X,
|
|
line.EndPoint.Y,
|
|
0,
|
|
0,
|
|
default,
|
|
line.Layer,
|
|
line.Suppressed
|
|
),
|
|
RapidMove rapid => new PartGeometryMotion(
|
|
rapid.Type,
|
|
rapid.EndPoint.X,
|
|
rapid.EndPoint.Y,
|
|
0,
|
|
0,
|
|
default,
|
|
default,
|
|
rapid.Suppressed
|
|
),
|
|
_ => throw new NotSupportedException(
|
|
"Geometry snapshots currently support only flat rapid/linear/arc programs."
|
|
),
|
|
}
|
|
);
|
|
return new PartGeometrySnapshot(program.Mode, motions);
|
|
}
|
|
}
|