7 Commits
Author SHA1 Message Date
aj c1af1d5433 fix(update): skip update checks when no Velopack locator is set
With GitHub as the default source, UpdaterService always built an
UpdateManager, which throws unless VelopackApp.Build().Run() has set a
locator. MainApp does, but WinForms test hosts constructing MainForm do
not, so 78 OpenNest.WinForms.Tests failed. Without a locator there is no
installation to update, so the service now stays idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d505a50934)
2026-10-27 18:54:40 -04:00
aj d6135bf9d0 feat(release): ship self-updating Velopack packages on GitHub Releases
Publish-Windows.ps1 now packs the smoke-tested folder with the pinned vpk
CLI (.config/dotnet-tools.json, 1.2.0 to match the Velopack package) into
velopack/: Setup.exe, Portable.zip, the full nupkg, releases.win.json and
RELEASES, checked for the full package and the portable layout and hashed
into OpenNest.vX.Y.Z.velopack.sha256. The release workflow uploads them
with the candidate.

With no feed URL set, installed copies now check GitHub Releases (full
releases only) instead of skipping update checks; a URL in Options still
selects a self-hosted feed. Deploy-OpenNest.ps1 uses the pinned vpk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 7fe4de4b6d)
2026-10-27 18:54:40 -04:00
aj 984b0419ab fix(planning): learn to reach a sheet separator from beyond the parts in its way
A sheet separator is cut after everything, so when the approach to its
pierce crossed parts already cut, the usual "cut it before them" rule
contradicted that and the search learned nothing. It fell back to the
full search, which spent the budget or found a route that climbed the
column and jumped back down past the cut parts (N26-23M).

When every "cut the blocked part first" rule would contradict a required
order, learn the other way round: cut the part the approach left from
before the parts it crossed, so the tool arrives from beyond them. A
separator blocked this way now ends the attempt at once, like any
cutoff whose rule can be learned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit f4317bc52f)
2026-10-27 18:54:40 -04:00
aj 86b78393d6 fix(ui): move the saved-nests Find bar above the grid
At the bottom of the form the search box read as part of the Details
panel. It now sits under the Nests header, next to the list it filters,
and comes first in the tab order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 207f126b9e)
2026-10-27 18:54:40 -04:00
aj 7474eaa989 fix(cutting): route a part's holes from the end of the segment cut before it
On P230323-02 plate 7 the frame part cut after a cut-off segment took its
upper small hole first, so the rapid up from the segment's end at the
bottom of the sheet crossed the rapid from the lower hole to the window.
The free search plans parts as if segments were absent, so the part's hole
route was ranked from where the tool was before the segment (the previous
part's end, top right), not from where it really is.

After CutOffInsertion places the segments, each holed part cut straight
after one now keeps its outside entry and order but takes the hole route
and preferred hole entries planned from the segment's end, when that
chain has an entry for every hole and passes the checks. The search's
look-through of segments keeps ranking outside entries from the
pre-segment point (so routes stay as planned without the lines) but plans
hole routes from the real position, so a kept-order replan of an applied
plan picks the same holes. Plates without cut-offs are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0ede9443de)
2026-10-27 18:54:40 -04:00
aj 242ef88a97 fix(ui): keep Draw Support visible while inspecting a cutting plan
The support overlay was drawn inside the live-plate-only paint block, and
the Plan Cutting preview plate never copied the support bed, so opening
the cut planner hid the grid points and each part's support marks. The
overlay now follows the displayed plate, the preview carries the bed, and
tab-point clearance falls back to the live plate's tab size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 58b8467c39)
2026-10-27 18:54:40 -04:00
aj 5cd7d4132c fix(ui): place bend lines in the program frame
Drawing.Bends stay in the DXF's source frame while CadImporter moves the
program so its first pierce sits at the origin (Source.Offset). The plate
renderer added part.Location to raw bend points, so every bend and etch
mark was shifted by its drawing's pierce offset, visible on the drawing
library set preview and on plates alike. Remove Source.Offset before
rotating and placing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit eedff85904)
2026-10-27 18:54:40 -04:00
25 changed files with 420 additions and 106 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"vpk": {
"version": "1.2.0",
"commands": [
"vpk"
],
"rollForward": false
}
}
}
+5
View File
@@ -96,6 +96,11 @@ jobs:
path: |
artifacts/*.zip
artifacts/*.sha256
artifacts/velopack/OpenNest-win-Setup.exe
artifacts/velopack/OpenNest-win-Portable.zip
artifacts/velopack/*-full.nupkg
artifacts/velopack/releases.win.json
artifacts/velopack/RELEASES
if-no-files-found: error
retention-days: 14
compression-level: 0
+1 -1
View File
@@ -70,6 +70,6 @@ Read the relevant contract before changing its behavior:
- [Cut-time estimates](docs/cut-time-estimates.md): simulator/Calculate Cut Time acceleration planner and placeholder machine limits
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
- [Desktop auto-update](docs/auto-update.md): GitHub Releases by default, `UpdateFeedUrl` for a self-hosted feed, release Velopack assets and `Deploy-OpenNest.ps1`.
- [Undo and Redo](docs/undo-redo.md): whole-nest history, the recording rule, what a restore rebuilds and what is excluded.
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install.
@@ -447,6 +447,8 @@ public sealed class CuttingPlanProposal
Quadrant = source.Quadrant,
PartSpacing = source.PartSpacing,
EdgeSpacing = source.EdgeSpacing,
// View > Draw Support shows the proposal on the same bed.
SupportBed = source.SupportBed,
};
// Machine clamps stay visible while inspecting the proposal; zones are immutable.
foreach (var zone in source.ClampZones)
@@ -52,7 +52,7 @@ internal static class JointCuttingPlanSearch
{
var inserted = CutOffInsertion.Insert(snapshot, outcome.Order, attached, walk.CountExpansion, token);
if (inserted != null)
return walk.Ready(inserted);
return walk.Ready(walk.FollowSegments(inserted));
}
catch (BudgetExceededException)
{
@@ -196,26 +196,35 @@ internal static class JointCuttingPlanSearch
}
// "Cut the blocked part before every part whose cut contour its approach crossed", unless that
// would contradict an order already required. False when nothing new was learned.
// would contradict an order already required. When every such rule would (a sheet separator
// is cut after everything), the blocked part cannot come earlier, so the tool must arrive
// from beyond those parts instead: "cut the part it left from before them". False when
// nothing new was learned.
private static bool Learn(Attempt attempt, HashSet<int>[] prerequisites)
{
if (attempt.Blocked is not int blocked)
return false;
var learned = false;
foreach (var crossed in attempt.Crossed.Order())
{
if (crossed == blocked || prerequisites[crossed].Contains(blocked) || Precedes(crossed, blocked, prerequisites))
continue;
prerequisites[crossed].Add(blocked);
learned = true;
}
return learned;
var rules = Rules(attempt, prerequisites).ToArray();
foreach (var (first, then) in rules)
prerequisites[then].Add(first);
return rules.Length != 0;
}
// True when a crossing of the blocked part's approach would teach a new order rule.
private static bool CanLearn(Attempt attempt, HashSet<int>[] prerequisites) =>
attempt.Blocked is int blocked && attempt.Crossed.Any(crossed => crossed != blocked
&& !prerequisites[crossed].Contains(blocked) && !Precedes(crossed, blocked, prerequisites));
Rules(attempt, prerequisites).Any();
// The rules Learn would add, as (cut first, cut then) pairs.
private static IEnumerable<(int First, int Then)> Rules(Attempt attempt, HashSet<int>[] prerequisites)
{
if (attempt.Blocked is not int blocked)
return [];
var before = New(blocked);
return before.Length != 0 || attempt.LastCut?.SourceOrdinal is not int departed ? before : New(departed);
(int, int)[] New(int first) => attempt.Crossed.Order()
.Where(crossed => crossed != blocked && crossed != first && !prerequisites[crossed].Contains(first)
&& !Precedes(crossed, first, prerequisites))
.Select(crossed => (first, crossed)).ToArray();
}
// True when 'first' must already come before 'second' through the prerequisite chain.
private static bool Precedes(int first, int second, HashSet<int>[] prerequisites)
@@ -334,10 +343,10 @@ internal static class JointCuttingPlanSearch
}
// A cutoff is a fixed program cut the same way wherever it goes, so replanning
// the parts before it can only change where its approach starts. Cutting it
// earlier never makes its own cut harder; learn that rather than stall here.
// earlier never makes its own cut harder, and a sheet separator that cannot come
// earlier needs the approach from elsewhere; learn that rather than stall here.
if (prerequisites != null && sequence != null && attempt.Blocked is int blocked
&& snapshot.Placements[blocked] is { IsCutOff: true, SeparatesSheet: false }
&& CanLearn(attempt, prerequisites))
&& snapshot.Placements[blocked].IsCutOff && CanLearn(attempt, prerequisites))
return attempt;
if (attempt.Advance(node))
progressExpansions = Expansions;
@@ -523,8 +532,9 @@ internal static class JointCuttingPlanSearch
CountExpansion(source);
var checker = node.Checker.Copy();
if (Check(cut, cut.Execution, node.Position, checker, attempt))
// A segment is transparent: the part after it ranks its entries and hole route
// from where the tool was before the segment, as planned without it.
// A segment is transparent to the part after it: that part ranks its outside entry
// from where the tool was before the segment, as planned without it. Its hole
// route starts where the tool is, the segment's end, as FollowSegments plans it.
yield return new(new([.. node.Order, cut], cut.Execution.DeparturePoint, checker, null, node,
IsSegment(cut) ? node.RoutePosition : null),
cut.Execution.RapidDistanceFrom(node.Position), cut.SourceOrdinal, -1, -1);
@@ -559,7 +569,7 @@ internal static class JointCuttingPlanSearch
}
var preference = node.Active?.Preference;
if (node.Active?.Perimeter is { } outside && preference == null)
preference = PlanHoles(node.Active, outside);
preference = PlanHoles(node.Active.Source, node.Active.Boundary.Position, outside);
var choices = node.Active?.Choices ?? [];
var arrival = node.Active?.Arrival ?? node.Position;
var before = node.Active?.Before ?? node.Checker;
@@ -1216,12 +1226,13 @@ internal static class JointCuttingPlanSearch
return ordered;
}
private HolePreference PlanHoles(ActivePart active, ContourChoice perimeter)
// The hole route and preferred hole entries from the tool's global arrival onto the part,
// ending at the outside entry.
private HolePreference PlanHoles(FixedProgramPlacement source, Vector arrival, ContourChoice perimeter)
{
var source = active.Source;
var prepared = source.Prepared;
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
var localArrival = active.Boundary.RoutePosition - source.Location;
var localArrival = arrival - source.Location;
var centres = CuttingHoleOrder.Centres(prepared, localArrival, token);
var endpoint = perimeter.Point;
var entries = new Dictionary<int, ContourChoice>();
@@ -1255,6 +1266,72 @@ internal static class JointCuttingPlanSearch
return new(route, entries, pierces, endpoint);
}
/// <summary>
/// The order with segments placed, where each holed part cut straight after a segment
/// takes the hole route and entries planned from the segment's end, where the tool
/// really is. The free search planned the part as if the segment were not there, from
/// where the tool was before it. The part's outside entry and the order stay, so the
/// route is still the one planned without the lines; a kept order replanned later plans
/// the same holes from the same point. A part keeps its planned holes when the new
/// route has no preferred entry for every hole or does not pass the checks.
/// </summary>
internal IReadOnlyList<FixedProgramPlacement> FollowSegments(IReadOnlyList<FixedProgramPlacement> order)
{
var followed = order.ToArray();
var checker = new ReleasedContourState(reportMissingLeadIns: false);
var position = snapshot.StartPoint;
try
{
for (var index = 0; index < followed.Length; index++)
{
if (index > 0 && IsSegment(followed[index - 1])
&& FromSegment(followed[index], position, checker) is { } replanned)
followed[index] = replanned;
var placement = followed[index];
checker.Check(placement.Execution, position, placement.SourceOrdinal + 1, placement.IsCutOff,
placement.FixedMotions(placement.Execution), token);
position = placement.Execution.DeparturePoint;
}
}
catch (BudgetExceededException)
{
// The plan is complete without this refinement; keep it as placed.
return order;
}
return followed;
}
// The part re-planned from arrival with its outside entry kept, or null to keep it.
private FixedProgramPlacement FromSegment(FixedProgramPlacement placement, Vector arrival,
ReleasedContourState checker)
{
var source = snapshot.Placements[placement.SourceOrdinal];
var prepared = source.Prepared;
if (prepared is not { HasCutPerimeter: true } || prepared.PerimeterOrdinal == 0
|| placement.ContourChoices.Count != prepared.Count
|| placement.ContourChoices[^1].ContourOrdinal != prepared.PerimeterOrdinal)
return null;
var perimeter = placement.ContourChoices[^1];
var preference = PlanHoles(source, arrival, perimeter);
if (preference.Entries.Count != prepared.PerimeterOrdinal)
return null;
var choices = preference.Route.Select(hole => preference.Entries[hole]).Append(perimeter).ToArray();
Program program;
OwnedExecution execution;
try
{
CountExpansion(source);
program = prepared.Emit(choices);
execution = ExecutionMotionReader.Read(program, source.Location, arrival, token);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException)
{
return null;
}
return Check(source, execution, arrival, checker.Copy(), new Attempt(null), choices)
? source.Propose(program, execution, choices, token) : null;
}
// The placements cut at a node, and the excluded ones, which count as cut throughout.
private HashSet<int> Finished(Node node)
{
@@ -1568,8 +1645,8 @@ internal static class JointCuttingPlanSearch
ContourChoice Perimeter = null, HolePreference Preference = null);
/// <summary>
/// A search state; Previous links a whole-part boundary to the boundary before it. Anchor,
/// when set, is where the tool was before the cut-off segments cut last: entries and hole
/// routes rank from <see cref="RoutePosition"/>, every rapid is checked from Position.
/// when set, is where the tool was before the cut-off segments cut last: outside entries
/// rank from <see cref="RoutePosition"/>; hole routes and every rapid start from Position.
/// </summary>
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
ActivePart Active, Node Previous, Vector? Anchor = null)
@@ -19,4 +19,17 @@ public class ClampZonePreviewTests
Assert.Equal(2, preview.ClampZones.Count);
Assert.Null(preview.ClampRail); // a detached display copy never re-resolves
}
[Fact]
public void PreviewCarriesThePlatesSupportBed()
{
var plate = BestEffortCuttingPlanTests.Plate(BestEffortCuttingPlanTests.TouchingContours());
plate.SupportBed = new OpenNest.Support.SupportGrid(25, 100, 2, 2, stabilityMargin: 0.1);
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
var preview = proposal.BuildPreview(0);
Assert.NotNull(preview);
Assert.Same(plate.SupportBed, preview.SupportBed);
}
}
@@ -109,6 +109,65 @@ public class CutOffSegmentPlanningTests
Assert.Equal(result.RapidDistance, kept.RapidDistance, 6);
}
// A frame with two small holes on its left strip, one above the other, and a window,
// between a locked square A at the top left, cut before it, and a locked square B to its
// right, cut after it. A line through the frame's right strip stops halfway up, so its one
// segment runs from the bottom edge to just below the frame and is cut straight before it.
// Planned without the segment, from A, the frame would cut its upper hole first; the tool
// is really at the bottom, so it cuts the lower hole first instead of crossing its own
// rapid. Replanned with the order kept, the frame plans the same holes from the same point.
[Fact]
public void PartAfterSegment_PlansItsHolesFromTheSegmentsEnd()
{
var parameters = ExplicitContourTests.Parameters();
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Size = new Size(60, 120);
plate.Parts.Add(LockedSquare("A", new Vector(2, 42), topLeft: false));
var frame = new Part(new Drawing("frame", Frame()), new Vector(30, 5));
plate.Parts.Add(frame);
plate.Parts.Add(LockedSquare("B", new Vector(80, 40), topLeft: false));
var cutOff = new CutOff(new Vector(65, 0), CutOffAxis.Vertical) { EndLimit = 25 };
plate.CutOffs.Add(cutOff);
plate.RegenerateCutOffs(new CutOffSettings());
PlateSequencing.PlaceCutOffs(plate, [cutOff]);
Assert.Single(CutOffSegments.Of(plate, cutOff));
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters));
Assert.True(result.Status == CuttingPlanStatus.Ready && result.IndependentlyReplayed, Describe(result));
var order = result.ProposedOrder.Select(o => o.SourcePart.BaseDrawing.IsCutOff ? "s" : o.SourcePart.BaseDrawing.Name);
Assert.Equal("A s frame B", string.Join(" ", order));
var planned = result.ProposedOrder[2];
var rapids = planned.Execution.Motions.Where(m => m.Rapid).Select(m => m.End).ToArray();
// Into the lower hole (y 14.5 to 15.1 on the sheet), then the upper one (34.5 to 35.1).
Assert.InRange(rapids[0].Y, 14, 16);
Assert.InRange(rapids[1].Y, 34, 36);
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
var kept = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, confirmedParameters: parameters,
preservePartOrder: true));
Assert.True(kept.Status == CuttingPlanStatus.Ready && kept.IndependentlyReplayed, Describe(kept));
Assert.Equal(result.RapidDistance, kept.RapidDistance, 6);
}
// 40 x 40 with 0.6 square holes at (2.5, 9.5) and (2.5, 29.5) and an 18 x 30 window.
private static Program Frame()
{
var program = new Program();
void Loop(params Vector[] points)
{
program.MoveTo(points[0].X, points[0].Y);
foreach (var point in points.Skip(1).Append(points[0]))
program.LineTo(point.X, point.Y);
}
Loop(new(0, 0), new(0, 40), new(40, 40), new(40, 0));
foreach (var y in new[] { 29.5, 9.5 })
Loop(new(2.5, y), new(3.1, y), new(3.1, y + 0.6), new(2.5, y + 0.6));
Loop(new(12, 5), new(30, 5), new(30, 35), new(12, 35));
return program;
}
private const double Gap = 0.25, Width = 10.6, Neck = 5.6, Height = 20, Margin = 0.25;
// The slanted shoulders need more horizontal room than the gap.
private const double Pitch = (Width + Neck) / 2 + 2 * Gap;
@@ -79,6 +79,43 @@ public class CuttingDependencyTests
&& f.Message.Contains("before the sheet is separated"));
}
// A column of two wide parts under three narrow ones, beside a sheet separator at X 12.5
// pierced at (12.5, 0). Climbing the column ends at the top, where the straight approach to
// that pierce crosses the wide parts, and the separator cannot be cut any earlier. The search
// learns the other way round, the part the approach left from before the parts it crossed,
// and so finishes beside the pierce. It used to learn nothing and spend its whole budget.
[Fact]
public void Plan_ApproachIntoASeparatorCrossingCutParts_LearnsToLeaveFromBeyondThem()
{
var (plate, column, separator) = ColumnBesideSeparator();
var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
confirmedParameters: ExplicitContourTests.Parameters()));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
var order = result.ProposedOrder.Select(o => o.SourcePart).ToList();
Assert.Same(separator, order[^1]);
Assert.Contains(order[^2], column[..2]);
Assert.True(result.Expansions < 400, Describe(result));
}
private static (Plate, Part[] Column, Part Separator) ColumnBesideSeparator()
{
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Size = new Size(100, 100);
Part[] column =
[
Rectangle(2, 2, 10, 10), Rectangle(2, 14, 10, 10),
Rectangle(2, 26, 5, 10), Rectangle(2, 38, 5, 10), Rectangle(2, 50, 5, 10),
];
foreach (var part in column)
plate.Parts.Add(part);
plate.CutOffs.Add(new CutOff(new Vector(12.5, 0), CutOffAxis.Vertical));
plate.RegenerateCutOffs(new CutOffSettings());
return (plate, column, plate.Parts.Single(p => p.BaseDrawing.IsCutOff));
}
// The tour cuts B, then the locked rectangle A (leaving right), then the cutoff pierced at
// (10, 0) below-left of A: that approach crosses A. Replanning B's entries can never fix it,
// so the search learns "cutoff first" at once instead of retrying each of B's entries.
@@ -0,0 +1,40 @@
using OpenNest.Bending;
using OpenNest.Controls;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.WinForms.Tests.Controls;
/// <summary>
/// Bends are kept in the drawing's source frame while its program starts at the origin, so a
/// placed part draws them with <c>Source.Offset</c> removed.
/// </summary>
public class BendLinePlacementTests
{
[Fact]
public void BendToWorld_RemovesSourceOffsetBeforePlacing()
{
var drawing = new Drawing("bent", new CNC.Program());
drawing.Source.Offset = new Vector(100, 50);
// In the source frame the bend runs across the part at local x = 5.
var bend = new Bend { StartPoint = new Vector(105, 50), EndPoint = new Vector(105, 60) };
var part = new Part(drawing, new Vector(10, 20));
Assert.Equal(new Vector(15, 20), PlateRenderer.BendToWorld(part, bend.StartPoint));
Assert.Equal(new Vector(15, 30), PlateRenderer.BendToWorld(part, bend.EndPoint));
}
[Fact]
public void BendToWorld_RotatesAboutTheProgramOrigin()
{
var drawing = new Drawing("bent", new CNC.Program());
drawing.Source.Offset = new Vector(100, 50);
var part = new Part(drawing, new Vector(10, 20));
part.Rotate(Angle.HalfPI, part.Location);
var world = PlateRenderer.BendToWorld(part, new Vector(105, 50));
Assert.Equal(10, world.X, 9);
Assert.Equal(25, world.Y, 9);
}
}
+16 -25
View File
@@ -465,25 +465,25 @@ namespace OpenNest.Controls
foreach (var bend in part.BaseDrawing.Bends)
{
var start = bend.StartPoint;
var end = bend.EndPoint;
if (part.Rotation != 0)
{
start = start.Rotate(part.Rotation);
end = end.Rotate(part.Rotation);
}
start = start + part.Location;
end = end + part.Location;
var pt1 = view.PointWorldToGraph(start);
var pt2 = view.PointWorldToGraph(end);
var pt1 = view.PointWorldToGraph(BendToWorld(part, bend.StartPoint));
var pt2 = view.PointWorldToGraph(BendToWorld(part, bend.EndPoint));
g.DrawLine(bendPen, pt1, pt2);
}
}
/// <summary>
/// Maps a bend point to world space. Bends stay in the drawing's source frame while the
/// program is moved by -<c>Source.Offset</c>, so the offset comes off before placing.
/// </summary>
internal static Vector BendToWorld(Part part, Vector point)
{
var local = point - part.BaseDrawing.Source.Offset;
if (part.Rotation != 0)
local = local.Rotate(part.Rotation);
return local + part.Location;
}
private void DrawEtchMarks(Graphics g, Part part, bool muted)
{
if (
@@ -501,17 +501,8 @@ namespace OpenNest.Controls
if (bend.Direction != BendDirection.Up)
continue;
var start = bend.StartPoint;
var end = bend.EndPoint;
if (part.Rotation != 0)
{
start = start.Rotate(part.Rotation);
end = end.Rotate(part.Rotation);
}
start = start + part.Location;
end = end + part.Location;
var start = BendToWorld(part, bend.StartPoint);
var end = BendToWorld(part, bend.EndPoint);
var length = bend.Length;
var angle = bend.StartPoint.AngleTo(bend.EndPoint) + part.Rotation;
+3 -2
View File
@@ -771,10 +771,11 @@ namespace OpenNest.Controls
{
renderer.DrawActiveWorkArea(e.Graphics);
renderer.DrawDebugRemnants(e.Graphics);
if (DrawSupport)
supportOverlay.Draw(e.Graphics, this);
DrawOverlapOverlay(e.Graphics);
}
// The support overlay follows the displayed plate, so it shows a proposal's tabbed parts too.
if (DrawSupport)
supportOverlay.Draw(e.Graphics, this);
// Plan Cutting marks findings on its proposal as well as on the live plate.
validationOverlay.Draw(e.Graphics);
if (!HasProposalDisplay)
+4 -2
View File
@@ -49,8 +49,10 @@ namespace OpenNest.Controls
using var supportedPen = new Pen(SupportedColor, 2f);
using var tippingPen = new Pen(TippingColor, 2f);
using var tabBrush = new SolidBrush(TabColor);
// Tab points keep clear of inside corners as Plan Cutting's do.
var clearance = SupportAnalyzer.InsideCornerClearance(plate.CuttingParameters?.TabConfig?.Size ?? 0);
// Tab points keep clear of inside corners as Plan Cutting's do. A proposal preview has no
// parameters of its own, so it uses the live plate's.
var parameters = plate.CuttingParameters ?? view.Plate?.CuttingParameters;
var clearance = SupportAnalyzer.InsideCornerClearance(parameters?.TabConfig?.Size ?? 0);
foreach (var part in plate.Parts)
{
if (part?.BaseDrawing == null || part.BaseDrawing.IsCutOff)
+1 -1
View File
@@ -267,7 +267,7 @@
this.updateGroupBox.Size = new System.Drawing.Size(684, 60);
this.updateGroupBox.TabIndex = 3;
this.updateGroupBox.TabStop = false;
this.updateGroupBox.Text = "Auto-update feed URL (blank disables update checks; set per machine)";
this.updateGroupBox.Text = "Auto-update feed URL (blank uses GitHub Releases; set per machine)";
//
// updateFeedUrlBox
//
+2 -2
View File
@@ -252,14 +252,14 @@ namespace OpenNest.Forms
Settings.Default.UpdateFeedUrl = "";
Settings.Default.Save();
updateFeedUrlBox.Text = "";
MessageBox.Show(this, "Update feed URL cleared. Update checks are disabled.",
MessageBox.Show(this, "Update feed URL cleared. Updates will come from GitHub Releases after a restart.",
"Update Feed URL", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (!NestStorageSettings.TryNormalizeServerUrl(text, out var url))
{
MessageBox.Show(this, "Enter a valid http or https feed URL, or clear the field to disable updates.",
MessageBox.Show(this, "Enter a valid http or https feed URL, or clear the field to update from GitHub Releases.",
"Update Feed URL", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
+11 -6
View File
@@ -307,18 +307,23 @@ public sealed class SavedNestsForm : Form
findBar.Controls.Add(nextButton);
findBar.Controls.Add(summaryLabel);
var bottom = new TableLayoutPanel
// The find bar sits between the Nests header and the grid it filters, and comes
// first in the tab order.
var findPanel = new TableLayoutPanel
{
Dock = DockStyle.Bottom,
Dock = DockStyle.Top,
AutoSize = true,
ColumnCount = 1,
Padding = new Padding(3, 3, 3, 6),
Padding = new Padding(3, 0, 3, 3),
TabIndex = 0,
};
bottom.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
bottom.Controls.Add(findBar);
findPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
findPanel.Controls.Add(findBar);
nestGrid.TabIndex = 1;
split.Panel1.Controls.Add(findPanel);
split.Panel1.Controls.SetChildIndex(findPanel, 1);
Controls.Add(split);
Controls.Add(bottom);
Load += (_, _) =>
{
+2 -2
View File
@@ -5,8 +5,8 @@ using System.Windows.Forms;
namespace OpenNest.Forms
{
/// <summary>
/// Background update checks against the per-machine feed URL (Options dialog; blank
/// disables checks) and the status-bar item that announces a downloaded update. Never
/// Background update checks (GitHub Releases, or the per-machine feed URL from the
/// Options dialog) and the status-bar item that announces a downloaded update. Never
/// restarts on its own: it restarts only when the user clicks the item and confirms.
/// </summary>
internal sealed class UpdaterStatus : IDisposable
+2 -2
View File
@@ -296,8 +296,8 @@ namespace OpenNest.Properties {
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("")]
// Velopack update feed base URL (e.g. http://host:port). Blank disables
// update checks; each machine sets its own value via Options, never committed.
// Velopack update feed base URL (e.g. http://host:port). Blank uses GitHub
// Releases; each machine sets its own value via Options, never committed.
public string UpdateFeedUrl {
get {
return ((string)(this["UpdateFeedUrl"]));
+13 -6
View File
@@ -3,6 +3,7 @@ using System.Threading;
using System.Threading.Tasks;
using OpenNest.Properties;
using Velopack;
using Velopack.Locators;
using Velopack.Sources;
namespace OpenNest
@@ -15,14 +16,15 @@ namespace OpenNest
/// <see cref="ApplyAndRestart"/> once the user agrees to restart.
/// </summary>
/// <remarks>
/// The feed URL is never hard-coded: it is read from the per-machine
/// <see cref="Settings.UpdateFeedUrl"/> setting (Options dialog), which is
/// user-scoped and lives outside source control. A blank URL disables
/// update checks entirely, which is the default for every install.
/// A blank <see cref="Settings.UpdateFeedUrl"/> (the default) checks the
/// public GitHub Releases for full, non-pre-release versions. A URL set in
/// the Options dialog points this machine at a self-hosted feed instead;
/// that URL is operator infrastructure and never lives in source control.
/// </remarks>
public sealed class UpdaterService : IDisposable
{
private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(30);
private const string GitHubRepositoryUrl = "https://github.com/ajisaacs/OpenNest";
private readonly UpdateManager _manager;
private Timer _timer;
@@ -36,10 +38,15 @@ namespace OpenNest
public UpdaterService(string feedUrl)
{
if (string.IsNullOrWhiteSpace(feedUrl))
// Only a process started through VelopackApp (MainApp) has a locator;
// test hosts and other embedders get no update checks.
if (!VelopackLocator.IsCurrentSet)
return;
_manager = new UpdateManager(new SimpleWebSource(feedUrl));
IUpdateSource source = string.IsNullOrWhiteSpace(feedUrl)
? new GithubSource(GitHubRepositoryUrl, null, false)
: new SimpleWebSource(feedUrl);
_manager = new UpdateManager(source);
}
/// <summary>True once a downloaded update is waiting for <see cref="ApplyAndRestart"/>.</summary>
+1 -1
View File
@@ -21,7 +21,7 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges
- Windows 10+ for the desktop app; the console, API, and most test projects build on Linux/macOS too.
- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) to build from source.
Prebuilt Windows downloads are on [GitHub Releases](https://github.com/ajisaacs/OpenNest/releases), or [build from source](#build-test-run) to run the latest code. Release ZIPs are self-contained: extract the entire archive into a new folder and run `OpenNest.exe`; no separate .NET installation is needed. To update, close OpenNest and extract the new release into a new folder; ZIP releases do not auto-update (optional installer-based updates need a separately hosted [Velopack feed](docs/auto-update.md)). All nesting engines (Default, Rectangles, Irregular, Circles, FlatBar and the fill strategies) are built in; see [nesting engines](docs/nesting-engines.md).
Prebuilt Windows downloads are on [GitHub Releases](https://github.com/ajisaacs/OpenNest/releases), or [build from source](#build-test-run) to run the latest code. Release ZIPs are self-contained: extract the entire archive into a new folder and run `OpenNest.exe`; no separate .NET installation is needed. For automatic updates, install with `OpenNest-win-Setup.exe` or extract `OpenNest-win-Portable.zip` instead: those copies download new full releases from GitHub in the background and restart only when you agree ([auto-update](docs/auto-update.md)). The plain ZIP does not auto-update; to update it, close OpenNest and extract the new release into a new folder. All nesting engines (Default, Rectangles, Irregular, Circles, FlatBar and the fill strategies) are built in; see [nesting engines](docs/nesting-engines.md).
## Build, Test, Run
+34 -16
View File
@@ -1,32 +1,49 @@
# Desktop auto-update
The Windows desktop app (`OpenNest.exe`) can self-update via
[Velopack](https://velopack.io): `OpenNest.UpdaterService` polls a static
file feed every 30 minutes, downloads a newer release in the background, and
[Velopack](https://velopack.io): `OpenNest.UpdaterService` polls its update
source every 30 minutes, downloads a newer release in the background, and
only restarts to apply it after the user confirms (a clickable status-bar
item appears once a download finishes). It never force-restarts; an open,
unsaved nest is never discarded automatically.
## Configuring the feed
## Where updates come from
The feed URL is saved for the current Windows user
(`OpenNest.Properties.Settings.UpdateFeedUrl`), set from **Tools > Options >
Auto-update feed URL**. It is blank by default, which disables update checks
entirely — this is intentional: the feed URL is operator infrastructure, not
something that belongs in source control, so no default ships in the repo.
Enter a feed's base URL (`http://host:port`) and restart the app to pick it
up.
By default an installed copy checks the public
[GitHub Releases](https://github.com/ajisaacs/OpenNest/releases) for full,
non-pre-release versions (`GithubSource`, unauthenticated: GitHub allows 60 API
requests per hour per IP address, ample for a 30-minute poll but shared by every
machine behind one address). Pre-releases are never offered.
Velopack only applies updates to an installed copy (`UpdateManager.IsInstalled`):
one installed with the `Setup.exe` that `Deploy-OpenNest.ps1` produces. The ZIPs on
GitHub Releases and ordinary build output are not Velopack installations and never
auto-update, even with a feed URL configured.
To use a self-hosted feed instead, set **Tools > Options > Auto-update feed URL**
(`OpenNest.Properties.Settings.UpdateFeedUrl`, saved for the current Windows
user) to the feed's base URL (`http://host:port`) and restart. A self-hosted
feed URL is operator infrastructure and never belongs in source control; clearing
the field returns to GitHub Releases.
Velopack only applies updates to a Velopack installation (`UpdateManager.IsInstalled`):
a copy installed with `OpenNest-win-Setup.exe` or extracted from
`OpenNest-win-Portable.zip`. The plain `OpenNest.vX.Y.Z.win-x64.zip` and ordinary
build output contain no `Update.exe` and never auto-update; moving from one of
them to an updating copy is a one-time manual install.
## Release packages
`scripts/Publish-Windows.ps1` packs the same smoke-tested folder as the plain ZIP
with the `vpk` CLI pinned in `.config/dotnet-tools.json` (keep it equal to the
`Velopack` package version), into `velopack/`: `OpenNest-win-Setup.exe`,
`OpenNest-win-Portable.zip`, `OpenNest-X.Y.Z-full.nupkg`, `releases.win.json`
and `RELEASES`, with their hashes in `OpenNest.vX.Y.Z.velopack.sha256`. Installed
copies find an update through the `releases.win.json` and full package attached to
the newest full GitHub Release, so all five must be uploaded with it (see
[releasing](releasing.md#publish)). Packages are full only; delta packages would
need the previous release's packages beside the output before `vpk pack`.
## Hosting a feed
Any static file server that can serve a `vpk pack` output directory works.
`scripts/Deploy-OpenNest.ps1` builds a self-contained `win-x64` publish,
packs it with the `vpk` CLI (`dotnet tool install -g vpk`), and `scp`s the
packs it with the pinned `vpk` CLI, and `scp`s the
result to a directory you provide:
```powershell
@@ -36,4 +53,5 @@ result to a directory you provide:
Bump `<Version>` in `OpenNest/OpenNest.csproj` before each run. This is
independent of [the GitHub release process](releasing.md) and of
`Publish-Windows.ps1` — the latter always overrides `<Version>` via
`-p:Version`, so the two don't conflict.
`-p:Version`, so the two don't conflict. Machines on a self-hosted feed only see
versions pushed to that feed.
+16 -5
View File
@@ -218,7 +218,10 @@ from the start point. Its 2-opt and Or-opt passes would grow with the square of
part count. Replans after a learned rule use the plain tour. If a part on that order cannot be
reached without crossing parts already cut, the search learns "cut this part
before those", backs up to just before the earliest of them and re-plans the rest
from the tool position there; parts cut before that point are kept. The first pass
from the tool position there; parts cut before that point are kept. When the part
cannot come earlier (a sheet separator is cut after everything), it learns the other
way round, "cut the part the approach left from before those", so the tool arrives
from beyond them. The first pass
along an order that teaches such a rule ends the round; the directed-recovery and
full passes run only when the lighter pass taught nothing. An attempt
stops backtracking after a stall of 8 x entries x contours expansions without
@@ -227,7 +230,8 @@ parts before it. The contours are the larger count of the blocked part and the p
cut just before it, whose departure a retry changes, so one many-holed part elsewhere
on the plate does not lengthen every stall. A cutoff is the exception: it is a fixed program and cutting it earlier never
makes its own cut harder, so an approach into a non-separating cutoff that crosses parts already
cut teaches "cut the cutoff before them" at once, without that stall. A rule that would contradict an order already required is skipped.
cut teaches "cut the cutoff before them" at once, without that stall, and one into a sheet
separator teaches its other-way-round rule at once. A rule that would contradict an order already required is skipped.
"Cut before" rules are a heuristic (a part blocked straight after another may be
reachable via a third), so once nothing new can be learned the remaining budget
first tries sequence-free recovery, visiting dependency-ready parts lazily by
@@ -247,7 +251,14 @@ segment that must precede some part (`CutOffInsertion`) goes as late as it can b
the first part in its span of the line, into the latest gap where both rapids cross
nothing cut before that point: the part after it keeps the program it was planned with,
and only its incoming rapid is checked again from the segment's end. Segments due in the
same gap are chained in the order placed, latest-due first. This keeps cutting local:
same gap are chained in the order placed, latest-due first. A holed part cut straight
after a segment was planned from where the tool was before the segment, but the tool is
at the segment's end, often across the part from there: its holes were routed toward the
wrong side and the rapid onto its first hole could cross the rapids between the others.
So once the segments are placed, each such part keeps its outside entry and its place in
the order, and takes the hole route and preferred hole entries planned from the
segment's end; it keeps its planned holes when that route has no preferred entry for
every hole or fails the checks. This keeps cutting local:
the segment below a row of parts is cut while the route passes it, not up front, and
never before a part it need not precede unless the geometry allows nothing later (a
segment whose pierce is walled off by every part cut after a point goes before that
@@ -258,8 +269,8 @@ separator or a line through open scrap precedes nothing and is planned like a pa
separator after everything, the other at its tour position.
Wherever a segment sits in an order, the search looks through it: the part before it
faces the next part, not the segment, and the part after it ranks its entries and hole
route from where the tool was before the segment, while every rapid is still checked
faces the next part, not the segment, and the part after it ranks its outside entry
from where the tool was before the segment, while its hole route and every rapid start
from the segment's end. A kept order with segments where a plan put them (an applied
plan replanned with `Keep the current part order`) therefore gets the entries and
routes the plan had, and replays Ready again.
+7 -1
View File
@@ -27,7 +27,8 @@ pulls from GitHub every hour; it refuses pushes.
(see [desktop help](desktop-help.md)), and `build-info.json` identifying the exact
source commit and help version. The nesting engines
are built into `OpenNest.Engine.dll`; no external engine repository or plug-in
DLL is packaged. `scripts/ReleaseSmoke` loads the packaged engine assembly and
DLL is packaged. Its `velopack/` folder holds the self-updating packages built
from the same folder; see [desktop auto-update](auto-update.md#release-packages). `scripts/ReleaseSmoke` loads the packaged engine assembly and
checks that every built-in engine instantiates from it, that the renamed
`Opus55NestingEngine` selection resolves to Irregular, and that an unknown
engine name is rejected.
@@ -54,6 +55,11 @@ After the candidate passes, integrate the release tooling into the chosen branch
create an annotated `vX.Y.Z` tag on the exact release commit and push it to
GitHub. Use the successful **tag build's** artifacts
for the GitHub Release; do not substitute packages built from another commit.
Upload the plain ZIP, its `.sha256`, `OpenNest.vX.Y.Z.velopack.sha256` and all five
files in `velopack/` as release assets, keeping their names. Installed copies update
only from the newest full (non-pre-release) Release that carries
`releases.win.json` and its `-full.nupkg`; a full Release without them leaves every
installation on its current version.
Verify the public asset names, sizes, download/checksum, and release status after
upload. Do not overwrite an existing release/tag or asset silently.
+2 -1
View File
@@ -75,7 +75,8 @@ inside corner. Only when every outline point is too close does the clearance giv
View > Draw Support shows the grid or the slats, cut off to the sheet (while its points or slats
are at least 6 pixels apart), each part's
contacts, and its center of gravity: green when supported, red when it can tip, with an orange
diamond on a red part's best tab point (keeping the plate's tab-size clearance).
diamond on a red part's best tab point (keeping the plate's tab-size clearance). While Plan
Cutting inspects a proposal, the overlay shows the proposal's parts on the same bed.
## Support tabs in Plan Cutting
+6 -7
View File
@@ -42,12 +42,10 @@ if ($csprojContent -notmatch '<Version>(?<v>[\d.]+)</Version>') { throw "Could n
$Version = $Matches.v
Write-Host "Packaging OpenNest v$Version"
# Ensure the vpk (Velopack) CLI is available
if (-not (Get-Command vpk -ErrorAction SilentlyContinue)) {
Write-Host "Installing vpk CLI (dotnet tool)..."
dotnet tool install -g vpk
if ($LASTEXITCODE -ne 0) { throw "dotnet tool install vpk failed" }
}
# The vpk (Velopack) CLI is pinned in .config/dotnet-tools.json to match the Velopack package
Push-Location $RepoRoot
try { & dotnet tool restore } finally { Pop-Location }
if ($LASTEXITCODE -ne 0) { throw "dotnet tool restore failed" }
$PublishDir = Join-Path $RepoRoot 'OpenNest\bin\publish-tmp'
$ReleasesDir = Join-Path $RepoRoot 'OpenNest\bin\releases'
@@ -61,7 +59,8 @@ $helpVersion = & (Join-Path $ScriptDir 'Install-Help.ps1') -Destination (Join-Pa
Write-Host "Packaged help $helpVersion"
Write-Host "Packing Velopack release v$Version..."
& vpk pack -u OpenNest -v $Version -p $PublishDir -e OpenNest.exe -o $ReleasesDir
Push-Location $RepoRoot
try { & dotnet tool run vpk pack -u OpenNest -v $Version -p $PublishDir -e OpenNest.exe -o $ReleasesDir } finally { Pop-Location }
if ($LASTEXITCODE -ne 0) { throw "vpk pack failed with exit code $LASTEXITCODE" }
Write-Host "Pushing release feed to $FeedHost..."
+27
View File
@@ -114,5 +114,32 @@ try {
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
"$hash $zipName" | Set-Content "$zip.sha256" -Encoding ascii
Write-Host "Verified $zipName ($hash), source $commit"
# Velopack packages of the same verified folder: Setup.exe and the portable ZIP
# self-update from GitHub Releases; the plain ZIP above never does.
dotnet tool restore
if ($LASTEXITCODE -ne 0) { throw 'dotnet tool restore failed.' }
$velopack = Join-Path $output 'velopack'
dotnet tool run vpk pack -u OpenNest -v $Version -p $package -e OpenNest.exe -o $velopack --packTitle OpenNest
if ($LASTEXITCODE -ne 0) { throw 'vpk pack failed.' }
$assets = @('OpenNest-win-Setup.exe', 'OpenNest-win-Portable.zip', "OpenNest-$Version-full.nupkg", 'releases.win.json', 'RELEASES')
foreach ($file in $assets) {
if (-not (Test-Path (Join-Path $velopack $file) -PathType Leaf)) { throw "Velopack output missing $file" }
}
$feed = Get-Content (Join-Path $velopack 'releases.win.json') -Raw | ConvertFrom-Json
if (@($feed.Assets | Where-Object { $_.Version -eq $Version -and $_.Type -eq 'Full' }).Count -ne 1) {
throw "releases.win.json does not list the $Version full package."
}
$portable = Join-Path $output 'velopack-portable'
Expand-Archive -Path (Join-Path $velopack 'OpenNest-win-Portable.zip') -DestinationPath $portable
foreach ($file in @('Update.exe') + ($required | ForEach-Object { "current/$_" })) {
if (-not (Test-Path (Join-Path $portable $file) -PathType Leaf)) { throw "Portable ZIP missing $file" }
}
$sums = foreach ($file in $assets) {
"$((Get-FileHash (Join-Path $velopack $file) -Algorithm SHA256).Hash.ToLowerInvariant()) $file"
}
$sums | Set-Content (Join-Path $output "OpenNest.v$Version.velopack.sha256") -Encoding ascii
Write-Host "Packed Velopack release $Version"
}
finally { Pop-Location }