fix(nest): anchor placed parts when an edit moves origin

Converter edits rebuild a drawing's program around its first pierce, so
Source.Offset can move (legacy drawings with no source entities, or a
tabbed/hidden contour changing the first cut). Part.Update kept the old
Location, shifting placed parts by the rotated origin delta - about 43
inches on a 270-degree part in the reported nest. Offset rebuilt parts
by that delta so their geometry stays put on the plate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-18 22:55:37 -04:00
1 parent e6c87df372
commit 0baa737162
2 files changed
+147 -9

No files matched your search

+20 -9
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest;
@@ -10,11 +11,11 @@ namespace OpenNest;
/// </summary>
public sealed class DrawingProgramSnapshot
{
private readonly Dictionary<Drawing, string> programs;
private readonly Dictionary<Drawing, Captured> programs;
private readonly Func<Program, string> fingerprint;
private DrawingProgramSnapshot(
Dictionary<Drawing, string> programs,
Dictionary<Drawing, Captured> programs,
Func<Program, string> fingerprint)
{
this.programs = programs;
@@ -32,12 +33,12 @@ public sealed class DrawingProgramSnapshot
{
ArgumentNullException.ThrowIfNull(drawings);
ArgumentNullException.ThrowIfNull(fingerprint);
var programs = new Dictionary<Drawing, string>(ReferenceEqualityComparer.Instance);
var programs = new Dictionary<Drawing, Captured>(ReferenceEqualityComparer.Instance);
foreach (var drawing in drawings)
{
if (!drawing.IsCutOff && !programs.ContainsKey(drawing))
programs.Add(drawing, fingerprint(drawing.Program));
programs.Add(drawing, new Captured(fingerprint(drawing.Program), SourceOffset(drawing)));
}
return new DrawingProgramSnapshot(programs, fingerprint);
@@ -45,20 +46,22 @@ public sealed class DrawingProgramSnapshot
/// <summary>
/// Complete the captured edit by rebuilding changed drawings' parts across all plates.
/// Part.Update preserves the placement and clears obsolete lead-ins, tabs and locks.
/// Part.Update clears obsolete lead-ins, tabs and locks; the placement is anchored to the
/// source frame, so a rebuild that re-origins the program (Source.Offset moved, e.g. a new
/// first pierce) leaves the geometry where it was on the plate.
/// Unchanged and uncaptured drawings' parts retain their program instances and state.
/// Returns the rebuilt parts so a UI can invalidate just their graphics.
/// </summary>
public IReadOnlyList<Part> UpdateChangedParts(IEnumerable<Plate> plates)
{
ArgumentNullException.ThrowIfNull(plates);
var changed = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
var originShifts = new Dictionary<Drawing, Vector>(ReferenceEqualityComparer.Instance);
foreach (var entry in programs)
{
if (!entry.Key.IsCutOff
&& !string.Equals(entry.Value, fingerprint(entry.Key.Program), StringComparison.Ordinal))
changed.Add(entry.Key);
&& !string.Equals(entry.Value.Fingerprint, fingerprint(entry.Key.Program), StringComparison.Ordinal))
originShifts.Add(entry.Key, SourceOffset(entry.Key) - entry.Value.SourceOffset);
}
var updated = new List<Part>();
@@ -66,14 +69,22 @@ public sealed class DrawingProgramSnapshot
{
foreach (var part in plate.Parts)
{
if (!changed.Contains(part.BaseDrawing))
if (!originShifts.TryGetValue(part.BaseDrawing, out var shift))
continue;
// A source point P sat at Location + R(P - old); keep it there with the new origin.
var rotation = part.Rotation;
part.Update();
if (shift != Vector.Zero)
part.Offset(shift.Rotate(rotation));
updated.Add(part);
}
}
return updated;
}
private static Vector SourceOffset(Drawing drawing) => drawing.Source?.Offset ?? Vector.Zero;
private readonly record struct Captured(string Fingerprint, Vector SourceOffset);
}
@@ -0,0 +1,127 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math;
using OpenNest.TabbedCutouts;
namespace OpenNest.Tests;
/// <summary>
/// Editing placed drawings in the converter rebuilds their programs around a new origin (the first
/// pierce). Placed parts must stay where they were on the plate, whatever the edit did to that origin.
/// </summary>
public class ConverterEditPlacementTests
{
[Fact]
public void TabbingAHoleInALegacyDrawing_KeepsPlacedPartsInPlace()
{
// Like drawings converted from CNC programs: no source entities, no source offset,
// and a program origin away from every pierce.
var program = new Program();
program.Codes.Add(new RapidMove(new Vector(-1.049212, -43.0103)));
program.Codes.Add(new ArcMove(new Vector(-1.049212, -43.0103), new Vector(-1.049212, -42.7603)));
program.Codes.Add(new RapidMove(new Vector(3.555639, -20.6378)));
program.Codes.Add(new ArcMove(new Vector(3.555639, -20.6378), new Vector(2.276111, -20.6378)));
program.Codes.Add(new RapidMove(new Vector(6.503735, -15.1378)));
program.Codes.Add(new LinearMove(new Vector(6.503735, -67.5753)));
program.Codes.Add(new LinearMove(new Vector(-1.946512, -67.5753)));
program.Codes.Add(new LinearMove(new Vector(-1.946512, -15.1378)));
program.Codes.Add(new LinearMove(new Vector(6.503735, -15.1378)));
var drawing = new Drawing("4980 A80-10 PT01", program);
Assert.Equal(Vector.Zero, drawing.Source.Offset);
var parts = new[]
{
Place(drawing, 3 * Angle.HalfPI, new Vector(52.6875, 35.9414)),
Place(drawing, Angle.ToRadians(37), new Vector(20, 10)),
};
ConverterEdit(drawing, entities =>
{
var hole = entities.OfType<Circle>().Single(c => c.Radius.IsEqualTo(1.279528));
return new TabbedCutout { EdgeIds = [hole.Id] };
}, parts, out var tabbed);
Assert.Single(tabbed.TabbedCutouts);
Assert.NotEqual(Vector.Zero, tabbed.Source.Offset);
}
[Fact]
public void HidingTheFirstCutHole_KeepsPlacedPartsInPlace()
{
var firstHole = new Circle(29, 15, 0.5);
var entities = new List<Entity>
{
new Line(20, 10, 32, 10),
new Line(32, 10, 32, 18),
new Line(32, 18, 20, 18),
new Line(20, 18, 20, 10),
firstHole,
new Circle(23, 13, 1),
};
var result = new CadImportResult { Entities = entities, Name = "Plate", SourcePath = "plate.dxf" };
var drawing = CadImporter.BuildDrawing(result, entities, result.Bends, 1, null, null);
var before = drawing.Source.Offset;
var parts = new[]
{
Place(drawing, 0, new Vector(5, 5)),
Place(drawing, Angle.HalfPI, new Vector(40, 5)),
Place(drawing, Angle.ToRadians(37), new Vector(70, 20)),
};
ConverterEdit(drawing, editable =>
{
editable.Single(e => e.Id == firstHole.Id).IsVisible = false;
return null;
}, parts, out var edited);
Assert.NotEqual(before, edited.Source.Offset);
}
private static Part Place(Drawing drawing, double rotation, Vector location)
{
var part = new Part(drawing);
part.Rotate(rotation);
part.Location = location;
return part;
}
/// <summary>
/// Runs the desktop's converter round trip on <paramref name="drawing"/> (snapshot, editable
/// entities, rebuild, in-place apply, part update) and asserts every part's placed bounds held.
/// </summary>
private static void ConverterEdit(
Drawing drawing,
Func<List<Entity>, TabbedCutout> edit,
Part[] parts,
out Drawing edited)
{
var plate = new Plate(120, 60);
foreach (var part in parts)
plate.Parts.Add(part);
var expected = parts.Select(p => p.BoundingBox).ToArray();
var snapshot = DrawingProgramSnapshot.Capture(
new[] { drawing },
program => NestWriter.GetProgramText(program) + "\0" + NestWriter.GetSubProgramsText(program));
var editable = CadImporter.EditableEntities(drawing);
var cutout = edit(editable);
// Derived program entities sit on SpecialLayers.Cut, whose visibility the converter's layer list owns.
var visible = editable.Where(e => e.IsVisible).ToList();
edited = CadImporter.BuildDrawing(
new CadImportResult { Entities = editable, Name = drawing.Name }, visible,
new List<OpenNest.Bending.Bend>(), 1, null, null, null,
cutout == null ? null : [cutout]);
CadImporter.ApplyEdit(drawing, edited);
Assert.Equal(parts, snapshot.UpdateChangedParts(new[] { plate }));
for (var i = 0; i < parts.Length; i++)
{
var actual = parts[i].BoundingBox;
Assert.Equal(expected[i].X, actual.X, 6);
Assert.Equal(expected[i].Y, actual.Y, 6);
Assert.Equal(expected[i].Length, actual.Length, 6);
Assert.Equal(expected[i].Width, actual.Width, 6);
}
}
}