Compare commits

..

8 Commits

Author SHA1 Message Date
aj 786b6e2e88 fix: show cutting parameters dialog before assigning lead-ins
Auto-assign lead-ins silently reused existing plate parameters with no
way to change them after the first assignment. Now a dialog with the
full CuttingPanel is shown every time, pre-populated with the current
settings, so the user can review and modify before confirming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:32:41 -04:00
aj ba89967448 fix: respect suppression state in filter panel and guard DetermineWinding
FilterPanel.LoadItem was hardcoding all layer and line type checkboxes
to checked, ignoring actual visibility state. Now reads Layer.IsVisible
and entity IsVisible to set correct checked state.

Also guard DetermineWinding against shapes with fewer than 3 polygon
points (defaults to CCW) to prevent crash when applying lead-ins.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:58:11 -04:00
aj b566d984b0 fix: preserve suppression state when reopening converter
LoadItem was resetting all entity visibility to true, overriding the
suppression state set by LoadDrawings. Now stores suppressed entity IDs
on FileListItem and re-applies after the reset. Also auto-unchecks
layers where all entities are suppressed, and syncs suppression state
back to the FileListItem when filters change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:32:18 -04:00
aj c1e6092e83 feat: add entity-based suppression with stable GUIDs
Entities now have a Guid Id for stable identity across edit sessions.
Drawing stores the full source entity set (SourceEntities) and a set of
suppressed entity IDs (SuppressedEntityIds), replacing the previous
SuppressedProgram approach. Unchecked entities in the converter are
suppressed rather than permanently removed, so they can be re-enabled
when editing drawings again.

Entities are serialized as JSON in the nest file (entities/entities-N)
alongside the existing G-code programs. Backward compatible with older
nest files that lack entity data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:25:48 -04:00
aj df86d4367b fix: update drawings in-place when editing in converter so parts reflect changes
EditDrawingsInConverter was replacing Drawing objects with new instances,
but Part.BaseDrawing is readonly — parts kept referencing the old drawings
with stale programs (e.g. etch lines that were removed). Now matches by
name and updates existing drawings in-place, then refreshes all parts.

Also fixes Part.Update() which applied rotation backwards and was missing
UpdateBounds() and lead-in state reset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:58:51 -04:00
aj 40026ab4dc test: add SpatialQuery DirectionalDistance tests for circles, squares, and rounded rects
24 tests covering circle-to-circle, square-to-square, rounded rectangle,
mixed shape types, PushDirection overload, edge cases, and symmetry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:10:32 -04:00
aj b18a82df7a refactor: clean up SpatialQuery duplications and redundancies
Extract ArcToLineClosestDistance helper to eliminate duplicate Phase 3
arc-to-line loops, remove redundant MaxValue guard in curve-to-curve
check, consolidate CollectVertices overloads, and add entity-based
PushDirection overload for API consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:33:29 -04:00
aj f090a2e299 fix: add arc-to-line closest-point check in DirectionalDistance
Corner arcs from offset perimeters could slip past vertex sampling,
causing compactor push to undershoot by ~halfSpacing. Use ClosestPointTo
to find the actual nearest point on each arc to each line before firing
the directional ray.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 08:52:30 -04:00
15 changed files with 884 additions and 68 deletions
@@ -309,7 +309,12 @@ namespace OpenNest.CNC.CuttingStrategy
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
return circle.Rotation;
return shape.ToPolygon().RotationDirection();
var polygon = shape.ToPolygon();
if (polygon.Vertices.Count < 3)
return RotationType.CCW;
return polygon.RotationDirection();
}
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle)
+13
View File
@@ -2,6 +2,7 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
@@ -90,6 +91,18 @@ namespace OpenNest
public List<Bend> Bends { get; set; } = new List<Bend>();
/// <summary>
/// Complete set of source entities with stable GUIDs.
/// Null when the drawing was created from G-code or an older nest file.
/// </summary>
public List<Entity> SourceEntities { get; set; }
/// <summary>
/// IDs of entities in <see cref="SourceEntities"/> that are suppressed (hidden).
/// Suppressed entities are excluded from the active Program but preserved for re-enabling.
/// </summary>
public HashSet<Guid> SuppressedEntityIds { get; set; } = new HashSet<Guid>();
public double Area { get; protected set; }
public void UpdateArea()
+7
View File
@@ -1,4 +1,5 @@
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Drawing;
@@ -10,10 +11,16 @@ namespace OpenNest.Geometry
protected Entity()
{
Id = Guid.NewGuid();
Layer = OpenNest.Geometry.Layer.Default;
boundingBox = new Box();
}
/// <summary>
/// Unique identifier for this entity, stable across edit sessions.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Entity color (resolved from DXF ByLayer/ByBlock to actual color).
/// </summary>
+48 -9
View File
@@ -510,6 +510,17 @@ namespace OpenNest.Geometry
return minDist;
}
/// <summary>
/// Computes the minimum translation distance along a push direction
/// before any vertex/edge of movingEntities contacts any vertex/edge of
/// stationaryEntities. Delegates to the Vector-based overload.
/// </summary>
public static double DirectionalDistance(
List<Entity> movingEntities, List<Entity> stationaryEntities, PushDirection direction)
{
return DirectionalDistance(movingEntities, stationaryEntities, DirectionToOffset(direction, 1.0));
}
/// <summary>
/// Computes the minimum translation distance along an arbitrary unit direction
/// before any vertex/edge of movingEntities contacts any vertex/edge of
@@ -562,7 +573,16 @@ namespace OpenNest.Geometry
}
}
// Phase 3: Curve-to-curve direct distance.
// Phase 3: Arc-to-line closest-point check.
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual
// closest point on a small corner arc to a straight edge may lie between
// those samples. Use ClosestPointTo to find it and fire a ray from there.
minDist = ArcToLineClosestDistance(movingEntities, stationaryEntities, dirX, dirY, minDist);
if (minDist <= 0) return 0;
minDist = ArcToLineClosestDistance(stationaryEntities, movingEntities, oppX, oppY, minDist);
if (minDist <= 0) return 0;
// Phase 4: Curve-to-curve direct distance.
// The vertex-to-entity approach misses the closest contact between two
// curved entities (circles/arcs) because only a few cardinal vertices are
// sampled. The true closest contact along the push direction is found by
@@ -582,7 +602,7 @@ namespace OpenNest.Geometry
var d = RayCircleDistance(mcx, mcy, scx, scy, mr + sr, dirX, dirY);
if (d >= minDist || d == double.MaxValue)
if (d >= minDist)
continue;
// For arcs, verify the contact point falls within both arcs' angular ranges.
@@ -616,6 +636,31 @@ namespace OpenNest.Geometry
return minDist;
}
private static double ArcToLineClosestDistance(
List<Entity> arcEntities, List<Entity> lineEntities,
double dirX, double dirY, double minDist)
{
for (var i = 0; i < arcEntities.Count; i++)
{
if (arcEntities[i] is Arc arc)
{
for (var j = 0; j < lineEntities.Count; j++)
{
if (lineEntities[j] is Line line)
{
var linePt = line.ClosestPointTo(arc.Center);
var arcPt = arc.ClosestPointTo(linePt);
var d = RayEdgeDistance(arcPt.X, arcPt.Y,
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
dirX, dirY);
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
}
}
}
}
return minDist;
}
private static double RayEntityDistance(
double vx, double vy, Entity entity, double dirX, double dirY)
{
@@ -695,13 +740,7 @@ namespace OpenNest.Geometry
private static HashSet<Vector> CollectVertices(List<Line> lines, Vector offset)
{
var vertices = new HashSet<Vector>();
for (var i = 0; i < lines.Count; i++)
{
vertices.Add(lines[i].pt1 + offset);
vertices.Add(lines[i].pt2 + offset);
}
return vertices;
return CollectVertices(ToEdgeArray(lines), offset);
}
private static HashSet<Vector> CollectVertices((Vector start, Vector end)[] edges, Vector offset)
+8 -1
View File
@@ -190,7 +190,14 @@ namespace OpenNest
{
var rotation = Rotation;
Program = BaseDrawing.Program.Clone() as Program;
Program.Rotate(Program.Rotation - rotation);
if (!Math.Tolerance.IsEqualTo(rotation, 0))
Program.Rotate(rotation);
HasManualLeadIns = false;
LeadInsLocked = false;
CuttingParameters = null;
UpdateBounds();
}
/// <summary>
+139
View File
@@ -0,0 +1,139 @@
using OpenNest.Geometry;
using System;
using System.Collections.Generic;
using System.Linq;
using static OpenNest.IO.NestFormat;
namespace OpenNest.IO
{
public static class EntitySerializer
{
public static EntitySetDto ToDto(List<Entity> entities, HashSet<Guid> suppressed)
{
return new EntitySetDto
{
Entities = entities.Select(ToEntityDto).ToList(),
Suppressed = suppressed.Select(id => id.ToString()).ToList()
};
}
public static (List<Entity> entities, HashSet<Guid> suppressed) FromDto(EntitySetDto dto)
{
var entities = dto.Entities.Select(FromEntityDto).ToList();
var suppressed = new HashSet<Guid>(dto.Suppressed.Select(Guid.Parse));
return (entities, suppressed);
}
private static EntityDto ToEntityDto(Entity entity)
{
switch (entity.Type)
{
case EntityType.Line:
var line = (Line)entity;
return new EntityDto
{
Id = entity.Id.ToString(),
Type = "line",
Layer = entity.Layer?.Name ?? "",
LineType = entity.LineTypeName ?? "",
X1 = line.StartPoint.X,
Y1 = line.StartPoint.Y,
X2 = line.EndPoint.X,
Y2 = line.EndPoint.Y
};
case EntityType.Arc:
var arc = (Arc)entity;
return new EntityDto
{
Id = entity.Id.ToString(),
Type = "arc",
Layer = entity.Layer?.Name ?? "",
LineType = entity.LineTypeName ?? "",
CX = arc.Center.X,
CY = arc.Center.Y,
R = arc.Radius,
StartAngle = arc.StartAngle,
EndAngle = arc.EndAngle,
Reversed = arc.IsReversed
};
case EntityType.Circle:
var circle = (Circle)entity;
return new EntityDto
{
Id = entity.Id.ToString(),
Type = "circle",
Layer = entity.Layer?.Name ?? "",
LineType = entity.LineTypeName ?? "",
CX = circle.Center.X,
CY = circle.Center.Y,
R = circle.Radius,
Rotation = circle.Rotation == RotationType.CW ? "CW" : "CCW"
};
default:
throw new NotSupportedException($"Entity type {entity.Type} is not supported for serialization.");
}
}
private static Entity FromEntityDto(EntityDto dto)
{
Entity entity;
switch (dto.Type)
{
case "line":
entity = new Line(
new Vector(dto.X1, dto.Y1),
new Vector(dto.X2, dto.Y2));
break;
case "arc":
entity = new Arc(
new Vector(dto.CX, dto.CY),
dto.R,
dto.StartAngle,
dto.EndAngle,
dto.Reversed);
break;
case "circle":
var circle = new Circle(new Vector(dto.CX, dto.CY), dto.R);
circle.Rotation = dto.Rotation == "CW" ? RotationType.CW : RotationType.CCW;
entity = circle;
break;
default:
throw new NotSupportedException($"Entity type '{dto.Type}' is not supported for deserialization.");
}
entity.Id = Guid.Parse(dto.Id);
entity.Layer = ResolveLayer(dto.Layer);
entity.LineTypeName = dto.LineType;
return entity;
}
private static Layer ResolveLayer(string name)
{
if (string.IsNullOrEmpty(name) || name == "0")
return Layer.Default;
if (string.Equals(name, SpecialLayers.Cut.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Cut;
if (string.Equals(name, SpecialLayers.Rapid.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Rapid;
if (string.Equals(name, SpecialLayers.Display.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Display;
if (string.Equals(name, SpecialLayers.Leadin.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Leadin;
if (string.Equals(name, SpecialLayers.Leadout.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Leadout;
if (string.Equals(name, SpecialLayers.Scribe.Name, StringComparison.OrdinalIgnoreCase))
return SpecialLayers.Scribe;
return new Layer(name);
}
}
}
+29
View File
@@ -162,6 +162,35 @@ namespace OpenNest.IO
public double Cost { get; init; }
}
public record EntitySetDto
{
public List<EntityDto> Entities { get; init; } = new();
public List<string> Suppressed { get; init; } = new();
}
public record EntityDto
{
public string Id { get; init; } = "";
public string Type { get; init; } = "";
public string Layer { get; init; } = "";
public string LineType { get; init; } = "";
// Line
public double X1 { get; init; }
public double Y1 { get; init; }
public double X2 { get; init; }
public double Y2 { get; init; }
// Arc / Circle
public double CX { get; init; }
public double CY { get; init; }
public double R { get; init; }
public double StartAngle { get; init; }
public double EndAngle { get; init; }
public bool Reversed { get; init; }
public string Rotation { get; init; } = "";
}
public record BestFitSetDto
{
public double PlateWidth { get; init; }
+27 -2
View File
@@ -36,7 +36,8 @@ namespace OpenNest.IO
var dto = JsonSerializer.Deserialize<NestDto>(nestJson, JsonOptions);
var programs = ReadPrograms(dto.Drawings.Count);
var drawingMap = BuildDrawings(dto, programs);
var entitySets = ReadEntitySets(dto.Drawings.Count);
var drawingMap = BuildDrawings(dto, programs, entitySets);
ReadBestFits(drawingMap);
var nest = BuildNest(dto, drawingMap);
@@ -74,7 +75,25 @@ namespace OpenNest.IO
return programs;
}
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs)
private Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> ReadEntitySets(int count)
{
var result = new Dictionary<int, (List<Entity>, HashSet<Guid>)>();
for (var i = 1; i <= count; i++)
{
var entry = zipArchive.GetEntry($"entities/entities-{i}");
if (entry == null) continue;
using var entryStream = entry.Open();
using var reader = new StreamReader(entryStream);
var json = reader.ReadToEnd();
var dto = JsonSerializer.Deserialize<EntitySetDto>(json, JsonOptions);
result[i] = EntitySerializer.FromDto(dto);
}
return result;
}
private Dictionary<int, Drawing> BuildDrawings(NestDto dto, Dictionary<int, Program> programs,
Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> entitySets)
{
var map = new Dictionary<int, Drawing>();
foreach (var d in dto.Drawings)
@@ -112,6 +131,12 @@ namespace OpenNest.IO
if (programs.TryGetValue(d.Id, out var pgm))
drawing.Program = pgm;
if (entitySets.TryGetValue(d.Id, out var entitySet))
{
drawing.SourceEntities = entitySet.entities;
drawing.SuppressedEntityIds = entitySet.suppressed;
}
map[d.Id] = drawing;
}
return map;
+19
View File
@@ -41,6 +41,7 @@ namespace OpenNest.IO
WriteNestJson(zipArchive);
WritePrograms(zipArchive);
WriteEntities(zipArchive);
WriteBestFits(zipArchive);
return true;
@@ -312,6 +313,24 @@ namespace OpenNest.IO
}
}
private void WriteEntities(ZipArchive zipArchive)
{
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
{
var drawing = kvp.Value;
if (drawing.SourceEntities == null || drawing.SourceEntities.Count == 0)
continue;
var dto = EntitySerializer.ToDto(drawing.SourceEntities, drawing.SuppressedEntityIds);
var json = JsonSerializer.Serialize(dto, JsonOptions);
var entry = zipArchive.CreateEntry($"entities/entities-{kvp.Key}");
using var stream = entry.Open();
using var writer = new StreamWriter(stream, Encoding.UTF8);
writer.Write(json);
}
}
private void WriteDrawing(Stream stream, Drawing drawing)
{
var program = drawing.Program;
@@ -0,0 +1,349 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Tests.Geometry;
public class SpatialQueryTests
{
#region Helpers
private static List<Entity> MakeSquare(double size)
{
return new List<Entity>
{
new Line(0, 0, size, 0),
new Line(size, 0, size, size),
new Line(size, size, 0, size),
new Line(0, size, 0, 0),
};
}
private static List<Entity> MakeRoundedRect(double length, double width, double r)
{
return new List<Entity>
{
new Line(r, 0, length - r, 0),
new Arc(length - r, r, r, Angle.ToRadians(270), Angle.ToRadians(360)),
new Line(length, r, length, width - r),
new Arc(length - r, width - r, r, Angle.ToRadians(0), Angle.ToRadians(90)),
new Line(length - r, width, r, width),
new Arc(r, width - r, r, Angle.ToRadians(90), Angle.ToRadians(180)),
new Line(0, width - r, 0, r),
new Arc(r, r, r, Angle.ToRadians(180), Angle.ToRadians(270)),
};
}
private static List<Entity> MakeCircle(double cx, double cy, double radius)
{
return new List<Entity> { new Circle(cx, cy, radius) };
}
private static List<Entity> Translate(List<Entity> entities, double dx, double dy)
{
var result = new List<Entity>();
foreach (var e in entities)
{
if (e is Line line)
result.Add(new Line(line.pt1.X + dx, line.pt1.Y + dy, line.pt2.X + dx, line.pt2.Y + dy));
else if (e is Arc arc)
result.Add(new Arc(arc.Center.X + dx, arc.Center.Y + dy, arc.Radius, arc.StartAngle, arc.EndAngle));
else if (e is Circle circle)
result.Add(new Circle(circle.Center.X + dx, circle.Center.Y + dy, circle.Radius));
}
return result;
}
#endregion
#region Circle vs Circle
[Fact]
public void CircleToCircle_Right_ReturnsGap()
{
var a = MakeCircle(0, 0, 5);
var b = MakeCircle(20, 0, 5);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void CircleToCircle_Left_ReturnsGap()
{
var a = MakeCircle(20, 0, 5);
var b = MakeCircle(0, 0, 5);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(-1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void CircleToCircle_Up_ReturnsGap()
{
var a = MakeCircle(0, 0, 5);
var b = MakeCircle(0, 20, 5);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(0, 1));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void CircleToCircle_Touching_ReturnsZero()
{
var a = MakeCircle(0, 0, 5);
var b = MakeCircle(10, 0, 5);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, -0.01, 0.01);
}
[Fact]
public void CircleToCircle_NoPath_ReturnsMaxValue()
{
var a = MakeCircle(0, 0, 3);
var b = MakeCircle(0, 20, 3);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.Equal(double.MaxValue, dist);
}
[Fact]
public void CircleToCircle_PushDirection_Right()
{
var a = MakeCircle(0, 0, 5);
var b = MakeCircle(20, 0, 5);
var dist = SpatialQuery.DirectionalDistance(a, b, PushDirection.Right);
Assert.InRange(dist, 9.9, 10.1);
}
#endregion
#region Square vs Square
[Fact]
public void SquareToSquare_Right_ReturnsGap()
{
var a = MakeSquare(10);
var b = Translate(MakeSquare(10), 25, 0);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, 14.9, 15.1);
}
[Fact]
public void SquareToSquare_Left_ReturnsGap()
{
var a = Translate(MakeSquare(10), 25, 0);
var b = MakeSquare(10);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(-1, 0));
Assert.InRange(dist, 14.9, 15.1);
}
[Fact]
public void SquareToSquare_Down_ReturnsGap()
{
var a = Translate(MakeSquare(10), 0, 25);
var b = MakeSquare(10);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(0, -1));
Assert.InRange(dist, 14.9, 15.1);
}
[Fact]
public void SquareToSquare_Touching_ReturnsZero()
{
var a = MakeSquare(10);
var b = Translate(MakeSquare(10), 10, 0);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, -0.01, 0.01);
}
[Fact]
public void SquareToSquare_NoOverlap_ReturnsMaxValue()
{
var a = MakeSquare(10);
var b = Translate(MakeSquare(10), 0, 20);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.Equal(double.MaxValue, dist);
}
[Fact]
public void SquareToSquare_PartialOverlap_Right()
{
var a = MakeSquare(10);
var b = Translate(MakeSquare(10), 20, 5);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
#endregion
#region Rounded Rectangle
[Fact]
public void RoundedRect_Right_ReturnsGap()
{
var a = MakeRoundedRect(20, 10, 2);
var b = Translate(MakeRoundedRect(20, 10, 2), 30, 0);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void RoundedRect_Up_ReturnsGap()
{
var a = MakeRoundedRect(20, 10, 2);
var b = Translate(MakeRoundedRect(20, 10, 2), 0, 25);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(0, 1));
Assert.InRange(dist, 14.9, 15.1);
}
[Fact]
public void RoundedRect_Touching_ReturnsZero()
{
var a = MakeRoundedRect(20, 10, 2);
var b = Translate(MakeRoundedRect(20, 10, 2), 20, 0);
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.InRange(dist, -0.01, 0.01);
}
[Fact]
public void RoundedRect_Diagonal_ReturnsDistance()
{
var dir = new Vector(1 / System.Math.Sqrt(2), 1 / System.Math.Sqrt(2));
var a = MakeRoundedRect(10, 10, 2);
var b = Translate(MakeRoundedRect(10, 10, 2), 20, 20);
var dist = SpatialQuery.DirectionalDistance(a, b, dir);
Assert.True(dist > 0 && dist < double.MaxValue);
}
#endregion
#region Circle vs Square
[Fact]
public void CircleToSquare_Right_ReturnsGap()
{
var circle = MakeCircle(0, 5, 5);
var square = Translate(MakeSquare(10), 15, 0);
var dist = SpatialQuery.DirectionalDistance(circle, square, new Vector(1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void SquareToCircle_Right_ReturnsGap()
{
var square = MakeSquare(10);
var circle = MakeCircle(25, 5, 5);
var dist = SpatialQuery.DirectionalDistance(square, circle, new Vector(1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void CircleToSquare_Touching_ReturnsZero()
{
var circle = MakeCircle(0, 5, 5);
var square = Translate(MakeSquare(10), 5, 0);
var dist = SpatialQuery.DirectionalDistance(circle, square, new Vector(1, 0));
Assert.InRange(dist, -0.01, 0.01);
}
#endregion
#region Circle vs Rounded Rectangle
[Fact]
public void CircleToRoundedRect_Right_ReturnsGap()
{
var circle = MakeCircle(0, 5, 5);
var rect = Translate(MakeRoundedRect(20, 10, 2), 15, 0);
var dist = SpatialQuery.DirectionalDistance(circle, rect, new Vector(1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
[Fact]
public void RoundedRectToCircle_Left_ReturnsGap()
{
var rect = Translate(MakeRoundedRect(20, 10, 2), 15, 0);
var circle = MakeCircle(0, 5, 5);
var dist = SpatialQuery.DirectionalDistance(rect, circle, new Vector(-1, 0));
Assert.InRange(dist, 9.9, 10.1);
}
#endregion
#region Edge cases
[Fact]
public void EmptyLists_ReturnsMaxValue()
{
var a = new List<Entity>();
var b = new List<Entity>();
var dist = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
Assert.Equal(double.MaxValue, dist);
}
[Fact]
public void Symmetry_LeftRightReturnSameDistance()
{
var a = MakeSquare(10);
var b = Translate(MakeSquare(10), 25, 0);
var right = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
var left = SpatialQuery.DirectionalDistance(b, a, new Vector(-1, 0));
Assert.InRange(System.Math.Abs(right - left), 0, 0.01);
}
[Fact]
public void Symmetry_CirclesLeftRightSame()
{
var a = MakeCircle(0, 0, 5);
var b = MakeCircle(20, 0, 5);
var right = SpatialQuery.DirectionalDistance(a, b, new Vector(1, 0));
var left = SpatialQuery.DirectionalDistance(b, a, new Vector(-1, 0));
Assert.InRange(System.Math.Abs(right - left), 0, 0.01);
}
#endregion
}
+1
View File
@@ -19,6 +19,7 @@ namespace OpenNest.Controls
public List<Entity> Entities { get; set; } = new();
public List<Entity> OriginalEntities { get; set; }
public List<Bend> Bends { get; set; } = new();
public HashSet<Guid> SuppressedEntityIds { get; set; }
public Box Bounds { get; set; }
public int EntityCount { get; set; }
}
+7 -6
View File
@@ -170,10 +170,11 @@ namespace OpenNest.Controls
layersList.Items.Clear();
var layers = entities
.Where(e => e.Layer != null)
.Select(e => e.Layer.Name)
.Distinct();
.Select(e => e.Layer)
.GroupBy(l => l.Name)
.Select(g => g.First());
foreach (var layer in layers)
layersList.Items.Add(layer, true); // checked = visible
layersList.Items.Add(layer.Name, layer.IsVisible);
layersPanel.HeaderText = $"Layers ({layersList.Items.Count})";
@@ -191,10 +192,10 @@ namespace OpenNest.Controls
// Line Types
lineTypesList.Items.Clear();
var lineTypes = entities
.Select(e => e.LineTypeName ?? "Continuous")
.Distinct();
.GroupBy(e => e.LineTypeName ?? "Continuous")
.Select(g => new { Name = g.Key, Visible = g.Any(e => e.IsVisible) });
foreach (var lt in lineTypes)
lineTypesList.Items.Add(lt, true); // checked = visible
lineTypesList.Items.Add(lt.Name, lt.Visible);
lineTypesPanel.HeaderText = $"Line Types ({lineTypesList.Items.Count})";
+84 -8
View File
@@ -169,6 +169,7 @@ namespace OpenNest.Forms
if (item.Entities.Any(e => e.Layer != null))
item.Entities.ForEach(e => e.Layer.IsVisible = true);
ReHidePromotedEntities(item.Bends);
ReHideSuppressedEntities(item);
filterPanel.LoadItem(item.Entities, item.Bends);
@@ -245,6 +246,7 @@ namespace OpenNest.Forms
filterPanel.ApplyFilters(item.Entities);
ReHidePromotedEntities(item.Bends);
SyncSuppressedState(item);
entityView1.Invalidate();
staleProgram = true;
}
@@ -610,17 +612,31 @@ namespace OpenNest.Forms
{
foreach (var drawing in drawings)
{
var entities = ConvertProgram.ToGeometry(drawing.Program);
List<Entity> entities;
// Re-apply source offset so entities appear in their natural position
if (drawing.Source?.Offset != null && drawing.Source.Offset != Vector.Zero)
if (drawing.SourceEntities != null)
{
foreach (var entity in entities)
entity.Offset(drawing.Source.Offset);
}
// Use stored entities with stable GUIDs; apply suppression state
entities = new List<Entity>(drawing.SourceEntities);
// Remove rapid traversals — they aren't part of the cut geometry
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
foreach (var entity in entities)
entity.IsVisible = !drawing.SuppressedEntityIds.Contains(entity.Id);
}
else
{
// Fallback: derive entities from Program (older drawings without source entities)
entities = ConvertProgram.ToGeometry(drawing.Program);
// Re-apply source offset so entities appear in their natural position
if (drawing.Source?.Offset != null && drawing.Source.Offset != Vector.Zero)
{
foreach (var entity in entities)
entity.Offset(drawing.Source.Offset);
}
// Remove rapid traversals — they aren't part of the cut geometry
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
}
var bounds = entities.GetBoundingBox();
@@ -632,6 +648,9 @@ namespace OpenNest.Forms
Quantity = drawing.Quantity.Required,
Customer = drawing.Customer ?? string.Empty,
Bends = drawing.Bends?.ToList() ?? new List<Bend>(),
SuppressedEntityIds = drawing.SuppressedEntityIds.Count > 0
? new HashSet<Guid>(drawing.SuppressedEntityIds)
: null,
Bounds = bounds,
EntityCount = entities.Count
};
@@ -682,6 +701,22 @@ namespace OpenNest.Forms
drawing.Program = programEditor.Program;
else
drawing.Program = pgm;
// Store all entities with stable GUIDs; track suppressed by ID
var bendSources = new HashSet<Entity>(
(item.Bends ?? new List<Bend>())
.Where(b => b.SourceEntity != null)
.Select(b => b.SourceEntity));
drawing.SourceEntities = item.Entities
.Where(e => !bendSources.Contains(e))
.ToList();
drawing.SuppressedEntityIds = new HashSet<Guid>(
drawing.SourceEntities
.Where(e => !(e.Layer.IsVisible && e.IsVisible))
.Select(e => e.Id));
drawings.Add(drawing);
Thread.Sleep(20);
@@ -704,6 +739,47 @@ namespace OpenNest.Forms
}
}
private static void ReHideSuppressedEntities(FileListItem item)
{
if (item.SuppressedEntityIds == null || item.SuppressedEntityIds.Count == 0)
return;
foreach (var entity in item.Entities)
{
if (item.SuppressedEntityIds.Contains(entity.Id))
entity.IsVisible = false;
}
// If all entities on a layer are suppressed, uncheck the layer too
var layerGroups = item.Entities
.Where(e => e.Layer != null)
.GroupBy(e => e.Layer);
foreach (var group in layerGroups)
{
if (group.All(e => !e.IsVisible))
group.Key.IsVisible = false;
}
}
private static void SyncSuppressedState(FileListItem item)
{
var bendSources = new HashSet<Entity>(
(item.Bends ?? new List<Bend>())
.Where(b => b.SourceEntity != null)
.Select(b => b.SourceEntity));
var suppressed = item.Entities
.Where(e => !(e.Layer.IsVisible && e.IsVisible))
.Where(e => !bendSources.Contains(e))
.Select(e => e.Id);
item.SuppressedEntityIds = new HashSet<Guid>(suppressed);
if (item.SuppressedEntityIds.Count == 0)
item.SuppressedEntityIds = null;
}
private static Color GetNextColor() => Drawing.GetNextColor();
+68
View File
@@ -0,0 +1,68 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Controls;
using System.Drawing;
using System.Windows.Forms;
namespace OpenNest.Forms
{
public class CuttingParametersDialog : Form
{
private readonly CuttingPanel cuttingPanel;
public CuttingParametersDialog()
{
Text = "Cutting Parameters";
Size = new Size(400, 560);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
cuttingPanel = new CuttingPanel
{
Dock = DockStyle.Fill
};
var buttonPanel = new Panel
{
Dock = DockStyle.Bottom,
Height = 40
};
var btnOk = new Button
{
Text = "OK",
DialogResult = DialogResult.OK,
Size = new Size(80, 28),
Location = new Point(220, 6)
};
var btnCancel = new Button
{
Text = "Cancel",
DialogResult = DialogResult.Cancel,
Size = new Size(80, 28),
Location = new Point(305, 6)
};
buttonPanel.Controls.Add(btnOk);
buttonPanel.Controls.Add(btnCancel);
Controls.Add(cuttingPanel);
Controls.Add(buttonPanel);
AcceptButton = btnOk;
CancelButton = btnCancel;
}
public void LoadParameters(CuttingParameters parameters)
{
cuttingPanel.LoadFromParameters(parameters);
}
public CuttingParameters GetParameters()
{
return cuttingPanel.BuildParameters();
}
}
}
+79 -41
View File
@@ -719,19 +719,17 @@ namespace OpenNest.Forms
var plate = PlateView.Plate;
if (plate.CuttingParameters == null)
{
var json = Properties.Settings.Default.CuttingParametersJson;
if (!string.IsNullOrEmpty(json))
{
try { plate.CuttingParameters = CuttingParametersSerializer.Deserialize(json); }
catch { plate.CuttingParameters = new CuttingParameters(); }
}
else
{
plate.CuttingParameters = new CuttingParameters();
}
}
var parameters = LoadOrDefaultParameters(plate.CuttingParameters);
using var dlg = new CuttingParametersDialog();
dlg.LoadParameters(parameters);
if (dlg.ShowDialog() != DialogResult.OK)
return;
parameters = dlg.GetParameters();
plate.CuttingParameters = parameters;
SaveCuttingParameters(parameters);
var assigner = new LeadInAssigner
{
@@ -782,17 +780,16 @@ namespace OpenNest.Forms
if (Nest == null)
return;
CuttingParameters parameters;
var json = Properties.Settings.Default.CuttingParametersJson;
if (!string.IsNullOrEmpty(json))
{
try { parameters = CuttingParametersSerializer.Deserialize(json); }
catch { parameters = new CuttingParameters(); }
}
else
{
parameters = new CuttingParameters();
}
var parameters = LoadOrDefaultParameters(PlateView?.Plate?.CuttingParameters);
using var dlg = new CuttingParametersDialog();
dlg.LoadParameters(parameters);
if (dlg.ShowDialog() != DialogResult.OK)
return;
parameters = dlg.GetParameters();
SaveCuttingParameters(parameters);
var assigner = new LeadInAssigner
{
@@ -840,24 +837,34 @@ namespace OpenNest.Forms
var plate = PlateView.Plate;
// If no cutting parameters exist, initialize from saved settings or defaults
if (plate.CuttingParameters == null)
{
var json = Properties.Settings.Default.CuttingParametersJson;
if (!string.IsNullOrEmpty(json))
{
try { plate.CuttingParameters = CuttingParametersSerializer.Deserialize(json); }
catch { plate.CuttingParameters = new CuttingParameters(); }
}
else
{
plate.CuttingParameters = new CuttingParameters();
}
}
plate.CuttingParameters = LoadOrDefaultParameters(null);
PlateView.SetAction(typeof(Actions.ActionLeadIn));
}
private static CuttingParameters LoadOrDefaultParameters(CuttingParameters existing)
{
if (existing != null)
return existing;
var json = Properties.Settings.Default.CuttingParametersJson;
if (!string.IsNullOrEmpty(json))
{
try { return CuttingParametersSerializer.Deserialize(json); }
catch { /* fall through */ }
}
return new CuttingParameters();
}
private static void SaveCuttingParameters(CuttingParameters parameters)
{
var json = CuttingParametersSerializer.Serialize(parameters);
Properties.Settings.Default.CuttingParametersJson = json;
Properties.Settings.Default.Save();
}
private void ImportDrawings_Click(object sender, EventArgs e)
{
Import();
@@ -874,11 +881,42 @@ namespace OpenNest.Forms
if (converter.ShowDialog() != DialogResult.OK)
return;
var drawings = converter.GetDrawings();
var newDrawings = converter.GetDrawings();
var newByName = newDrawings.ToDictionary(d => d.Name);
// Replace all drawings — clear existing and add new ones
Nest.Drawings.Clear();
drawings.ForEach(d => Nest.Drawings.Add(d));
// Update existing drawings in-place so parts keep their BaseDrawing references
foreach (var existing in Nest.Drawings.ToList())
{
if (newByName.TryGetValue(existing.Name, out var updated))
{
existing.Program = updated.Program;
existing.SourceEntities = updated.SourceEntities;
existing.SuppressedEntityIds = updated.SuppressedEntityIds;
existing.Source = updated.Source;
existing.Customer = updated.Customer;
existing.Quantity.Required = updated.Quantity.Required;
existing.Bends.Clear();
existing.Bends.AddRange(updated.Bends);
newByName.Remove(existing.Name);
}
else
{
Nest.Drawings.Remove(existing);
}
}
// Add any new drawings that weren't in the original set
foreach (var d in newByName.Values)
Nest.Drawings.Add(d);
// Refresh all parts to use the updated programs
foreach (var plate in Nest.Plates)
foreach (var part in plate.Parts)
if (!part.BaseDrawing.IsCutOff)
part.Update();
UpdateDrawingList();
PlateView.Invalidate();
}
private void CleanUnusedDrawings_Click(object sender, EventArgs e)