Files
OpenNest/OpenNest.Engine/Jobs/NestPipelineDrawingFreshness.cs
T
ajandClaude Opus 5.5 54ad7ee84d feat(server): nest Auto Nest jobs on the server's worker
Large nests tie up the workstation that runs them. In Database mode, Auto Nest
can now queue the job on the nest server, where a worker container solves one
job at a time under its own CPU and memory limits. The job has no time limit,
so a throttled worker finds the same nest a fast one does, only later.

- Server: /api/nest-jobs queue (schema v15). The worker holds a job on a
  heartbeat lease. When the lease lapses, the job goes back to the queue,
  or fails after three attempts. Only one job runs at a time. Packages and
  results are opaque to the HTTP server, which stays off Engine and IO.
- Worker: OpenNest.Server.Worker in the same image (OPENNEST_ROLE=worker),
  reaching the server over HTTP only. It records each engine answer with
  RecordingNestingEngine and gives its job back on shutdown.
- Desktop: "Nest on the server" in the Auto Nest dialog, for built-in
  engines only. The client replays the recorded answers with
  RemoteNestSubmission, so validation, the review and Keep run locally
  against the client's own drawings, exactly as after a local run. A
  drawing edited after sending refuses the result. If the server cannot
  take the job, the user may nest locally instead.
- compose.server.yaml adds the throttled worker service. The Dockerfile,
  .dockerignore, CI paths and the deploy script now include the worker's
  projects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:18:09 -05:00

110 lines
5.2 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
/// <summary>Refuse a proposal when a caller drawing no longer matches its checked snapshot.</summary>
internal static class NestPipelineDrawingFreshness
{
internal static IReadOnlyList<string> Changes(NestJob job,
IReadOnlyDictionary<string, Drawing> drawings)
{
var changed = new List<string>();
foreach (var requirement in job.Parts)
{
if (!drawings.TryGetValue(requirement.Id, out var drawing)
|| drawing?.Program == null)
{
changed.Add($"Drawing for '{requirement.Id}' changed after the nesting snapshot");
continue;
}
try
{
var current = PartGeometrySnapshot.FromProgram(drawing.Program);
if (Same(requirement.Geometry, current))
continue;
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
or InvalidOperationException)
{
// A now-unsupported program is not the geometry that was checked.
}
changed.Add($"Drawing '{drawing.Name ?? requirement.Id}' changed after the nesting snapshot");
}
return changed;
}
/// <summary>Check the actual cloned programs, not only the drawing before/after
/// binding. A transient edit during Clone may leave the drawing unchanged.</summary>
internal static IReadOnlyList<string> BoundChanges(NestJob job, NestJobResult raw,
IReadOnlyList<ProposedPlate> plates, IReadOnlyDictionary<string, Drawing> drawings,
bool consolidate = false, CancellationToken token = default)
{
var changed = new List<string>();
var requirements = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
var groups = RepeatedPlateGrouping.Create(raw.Plates, consolidate, token);
if (plates.Count != groups.Count)
return new[] { "Bound plate count changed after validation" };
for (var sheetIndex = 0; sheetIndex < plates.Count; sheetIndex++)
{
token.ThrowIfCancellationRequested();
var group = groups[sheetIndex];
var proposed = plates[sheetIndex];
if (proposed.Quantity != group.Quantity || proposed.PlateIndex != group.Sheet.PlateIndex
|| !RepeatedPlateGrouping.SameStock(proposed.Stock, group.Sheet.Stock))
return new[] { "Bound plate identity or quantity changed after validation" };
var poses = group.Sheet.Placements;
var bound = proposed.Parts;
if (bound.Count != poses.Count)
return new[] { "Bound part count changed after validation" };
for (var index = 0; index < poses.Count; index++)
{
token.ThrowIfCancellationRequested();
var pose = poses[index];
var actual = bound[index];
if (pose.PartId == null || !requirements.TryGetValue(pose.PartId, out var requirement)
|| !drawings.TryGetValue(pose.PartId, out var drawing)
|| !ReferenceEquals(actual.BaseDrawing, drawing))
return new[] { "A bound part no longer matches its requirement" };
try
{
var expected = new Part(DrawingJobMapper.CreateDrawing(requirement));
expected.Rotate(pose.Rotation);
expected.Location = new Vector(pose.X, pose.Y);
if (Same(PartGeometrySnapshot.FromProgram(expected.Program),
PartGeometrySnapshot.FromProgram(actual.Program))
&& Bits(expected.Location.X) == Bits(actual.Location.X)
&& Bits(expected.Location.Y) == Bits(actual.Location.Y)
&& Bits(expected.Rotation) == Bits(actual.Rotation))
continue;
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException
or InvalidOperationException)
{
// A changed/unsupported program cannot represent the checked pose.
}
changed.Add($"Bound part for '{drawing.Name ?? requirement.Id}' changed after validation");
}
}
return changed;
}
internal static bool Same(PartGeometrySnapshot a, PartGeometrySnapshot b) =>
a.Mode == b.Mode && a.Motions.Count == b.Motions.Count
&& a.Motions.Zip(b.Motions).All(pair =>
pair.First.Type == pair.Second.Type
&& Bits(pair.First.X) == Bits(pair.Second.X)
&& Bits(pair.First.Y) == Bits(pair.Second.Y)
&& Bits(pair.First.CenterX) == Bits(pair.Second.CenterX)
&& Bits(pair.First.CenterY) == Bits(pair.Second.CenterY)
&& pair.First.Rotation == pair.Second.Rotation
&& pair.First.Layer == pair.Second.Layer
&& pair.First.Suppressed == pair.Second.Suppressed);
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
}