diff --git a/OpenNest.Core/Converters/EntityRoles.cs b/OpenNest.Core/Converters/EntityRoles.cs
new file mode 100644
index 00000000..ac1f94df
--- /dev/null
+++ b/OpenNest.Core/Converters/EntityRoles.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest.Converters
+{
+ /// What a drawing does with a source entity.
+ public enum EntityRole
+ {
+ /// Cut through the material.
+ Cut,
+
+ /// Marked on the surface (a Scribe pass): never material.
+ Etch,
+
+ ///
+ /// Shown with the part for reference only. It is kept out of the program, so it is never
+ /// cut, posted, nested against or verified.
+ ///
+ Display,
+ }
+
+ ///
+ /// Applies per-entity role overrides. An entity without an override keeps the role its layer
+ /// implies: mark layers (ETCH, SCRIBE, ENGRAVE) etch, everything else cuts.
+ ///
+ public static class EntityRoles
+ {
+ public static EntityRole Default(Entity entity) =>
+ ScribeLayerRepair.IsMarkLayer(entity.Layer) ? EntityRole.Etch : EntityRole.Cut;
+
+ public static EntityRole Of(Entity entity, IReadOnlyDictionary overrides) =>
+ overrides != null && overrides.TryGetValue(entity.Id, out var role) ? role : Default(entity);
+
+ ///
+ /// The entities a program is built from: display-only entities are left out and entities
+ /// whose override differs from their layer's role are replaced by copies, with the same
+ /// ID, on a layer the converters read as that role. Inputs are never mutated.
+ ///
+ public static List ForProgram(IEnumerable entities, IReadOnlyDictionary overrides)
+ {
+ var result = new List();
+ foreach (var entity in entities)
+ {
+ if (overrides == null || !overrides.TryGetValue(entity.Id, out var role) || role == Default(entity))
+ {
+ result.Add(entity);
+ continue;
+ }
+ if (role == EntityRole.Display)
+ continue;
+
+ var copy = entity.Clone();
+ copy.Id = entity.Id;
+ copy.Layer = new Layer(role == EntityRole.Etch ? SpecialLayers.Scribe.Name : SpecialLayers.Cut.Name)
+ {
+ Color = entity.Layer?.Color ?? SpecialLayers.Cut.Color,
+ IsVisible = true,
+ };
+ result.Add(copy);
+ }
+ return result;
+ }
+
+ ///
+ /// A drawing's unsuppressed display-only entities as copies in its program frame (source
+ /// coordinates shifted by -Source.Offset, before any program rotation).
+ ///
+ public static List DisplayGeometry(Drawing drawing)
+ {
+ var result = new List();
+ if (drawing?.SourceEntities == null || drawing.EntityRoles == null || drawing.EntityRoles.Count == 0)
+ return result;
+
+ var offset = drawing.Source?.Offset ?? Vector.Zero;
+ foreach (var entity in drawing.SourceEntities)
+ {
+ if (drawing.SuppressedEntityIds.Contains(entity.Id) || Of(entity, drawing.EntityRoles) != EntityRole.Display)
+ continue;
+ var copy = entity.Clone();
+ copy.Id = entity.Id;
+ copy.Offset(-offset.X, -offset.Y);
+ result.Add(copy);
+ }
+ return result;
+ }
+ }
+}
diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs
index b49f738d..a5712b37 100644
--- a/OpenNest.Core/Drawing.cs
+++ b/OpenNest.Core/Drawing.cs
@@ -156,6 +156,14 @@ namespace OpenNest
///
public HashSet SuppressedEntityIds { get; set; } = new HashSet();
+ ///
+ /// Role overrides for entities in ; entities not listed keep the
+ /// role their layer implies (see ). already has
+ /// them applied; display-only entities are not in it and come from
+ /// .
+ ///
+ public Dictionary EntityRoles { get; set; } = new Dictionary();
+
public double Area { get; protected set; }
public void UpdateArea()
diff --git a/OpenNest.IO/CadImporter.cs b/OpenNest.IO/CadImporter.cs
index ece1e966..0350ea2a 100644
--- a/OpenNest.IO/CadImporter.cs
+++ b/OpenNest.IO/CadImporter.cs
@@ -140,6 +140,10 @@ namespace OpenNest.IO
/// Tabbed cutouts to merge into the program (after breakoffs) and keep on the drawing;
/// ones that cannot be resolved are kept but not applied.
///
+ ///
+ /// Per-entity role overrides keyed by source entity ID, applied before breakoffs and kept
+ /// on the drawing; display-only entities stay out of the program.
+ ///
public static Drawing BuildDrawing(
CadImportResult result,
IEnumerable entities,
@@ -148,10 +152,13 @@ namespace OpenNest.IO
string customer,
OpenNest.CNC.Program editedProgram,
IReadOnlyList breakoffs = null,
- IReadOnlyList tabbedCutouts = null
+ IReadOnlyList tabbedCutouts = null,
+ IReadOnlyDictionary entityRoles = null
)
{
- var visible = entities as IList ?? new List(entities);
+ var visible = entityRoles is { Count: > 0 }
+ ? EntityRoles.ForProgram(entities, entityRoles)
+ : entities as IList ?? new List(entities);
var bendList = bends as IList ?? new List(bends);
var cut = breakoffs is { Count: > 0 }
@@ -201,6 +208,11 @@ namespace OpenNest.IO
.Select(e => e.Id)
);
+ var sourceIds = new HashSet(drawing.SourceEntities.Select(e => e.Id));
+ drawing.EntityRoles = entityRoles?
+ .Where(r => sourceIds.Contains(r.Key))
+ .ToDictionary(r => r.Key, r => r.Value) ?? new Dictionary();
+
return drawing;
}
@@ -248,6 +260,7 @@ namespace OpenNest.IO
existing.Program = edited.Program;
existing.SourceEntities = edited.SourceEntities;
existing.SuppressedEntityIds = edited.SuppressedEntityIds;
+ existing.EntityRoles = edited.EntityRoles;
existing.Source = edited.Source;
existing.Customer = edited.Customer;
existing.Quantity.Required = edited.Quantity.Required;
diff --git a/OpenNest.IO/EntitySerializer.cs b/OpenNest.IO/EntitySerializer.cs
index d79b069e..0e1e5ae6 100644
--- a/OpenNest.IO/EntitySerializer.cs
+++ b/OpenNest.IO/EntitySerializer.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using OpenNest.Converters;
using OpenNest.Geometry;
using static OpenNest.IO.NestFormat;
@@ -8,12 +9,19 @@ namespace OpenNest.IO
{
public static class EntitySerializer
{
- public static EntitySetDto ToDto(List entities, HashSet suppressed)
+ public static EntitySetDto ToDto(
+ List entities,
+ HashSet suppressed,
+ IReadOnlyDictionary roles = null
+ )
{
return new EntitySetDto
{
Entities = entities.Select(ToEntityDto).ToList(),
Suppressed = suppressed.Select(id => id.ToString()).ToList(),
+ Roles = roles is { Count: > 0 }
+ ? roles.OrderBy(r => r.Key).ToDictionary(r => r.Key.ToString(), r => RoleName(r.Value))
+ : null,
};
}
@@ -24,6 +32,50 @@ namespace OpenNest.IO
return (entities, suppressed);
}
+ ///
+ /// Role overrides of . Unknown role names and malformed IDs are
+ /// skipped, so those entities keep their layer's role.
+ ///
+ public static Dictionary RolesFromDto(EntitySetDto dto)
+ {
+ var roles = new Dictionary();
+ if (dto.Roles == null)
+ return roles;
+ foreach (var (id, name) in dto.Roles)
+ {
+ if (Guid.TryParse(id, out var guid) && TryParseRole(name, out var role))
+ roles[guid] = role;
+ }
+ return roles;
+ }
+
+ private static string RoleName(EntityRole role) =>
+ role switch
+ {
+ EntityRole.Etch => "etch",
+ EntityRole.Display => "display",
+ _ => "cut",
+ };
+
+ private static bool TryParseRole(string name, out EntityRole role)
+ {
+ switch (name?.ToLowerInvariant())
+ {
+ case "cut":
+ role = EntityRole.Cut;
+ return true;
+ case "etch":
+ role = EntityRole.Etch;
+ return true;
+ case "display":
+ role = EntityRole.Display;
+ return true;
+ default:
+ role = EntityRole.Cut;
+ return false;
+ }
+ }
+
private static EntityDto ToEntityDto(Entity entity)
{
switch (entity.Type)
diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs
index 1985524f..ea2665dd 100644
--- a/OpenNest.IO/NestFormat.cs
+++ b/OpenNest.IO/NestFormat.cs
@@ -552,6 +552,10 @@ namespace OpenNest.IO
{
public List Entities { get; init; } = new();
public List Suppressed { get; init; } = new();
+
+ /// Role overrides by entity ID ("cut", "etch" or "display"); omitted when none.
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public Dictionary Roles { get; init; }
}
public record EntityDto
diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs
index 6a9c61d8..ba7b7ec7 100644
--- a/OpenNest.IO/NestReader.cs
+++ b/OpenNest.IO/NestReader.cs
@@ -186,11 +186,11 @@ namespace OpenNest.IO
return reader.Read();
}
- private Dictionary entities, HashSet suppressed)> ReadEntitySets(
+ private Dictionary entities, HashSet suppressed, Dictionary roles)> ReadEntitySets(
int count
)
{
- var result = new Dictionary, HashSet)>();
+ var result = new Dictionary, HashSet, Dictionary)>();
for (var i = 1; i <= count; i++)
{
var entry = zipArchive.GetEntry($"entities/entities-{i}");
@@ -201,7 +201,8 @@ namespace OpenNest.IO
using var reader = new StreamReader(entryStream);
var json = reader.ReadToEnd();
var dto = JsonSerializer.Deserialize(json, JsonOptions);
- result[i] = EntitySerializer.FromDto(dto);
+ var (entities, suppressed) = EntitySerializer.FromDto(dto);
+ result[i] = (entities, suppressed, EntitySerializer.RolesFromDto(dto));
}
return result;
}
@@ -209,7 +210,7 @@ namespace OpenNest.IO
private Dictionary BuildDrawings(
NestDto dto,
Dictionary programs,
- Dictionary entities, HashSet suppressed)> entitySets
+ Dictionary entities, HashSet suppressed, Dictionary roles)> entitySets
)
{
var map = new Dictionary();
@@ -264,12 +265,16 @@ namespace OpenNest.IO
{
drawing.SourceEntities = entitySet.entities;
drawing.SuppressedEntityIds = entitySet.suppressed;
+ drawing.EntityRoles = entitySet.roles;
// Older files saved etch marks as cut moves; restore their Scribe layer so
- // nesting ignores them. The program was mutated in place, so refresh.
+ // nesting ignores them. The program was mutated in place, so refresh. Marks
+ // the user set to cut or display were not etched, so they are not repair sources.
+ var marks = drawing.SourceEntities.Where(e =>
+ EntityRoles.Of(e, drawing.EntityRoles) == EntityRole.Etch);
if (
drawing.Program != null
- && ScribeLayerRepair.Apply(drawing.Program, drawing.SourceEntities, drawing.Source.Offset) > 0
+ && ScribeLayerRepair.Apply(drawing.Program, marks, drawing.Source.Offset) > 0
)
{
drawing.UpdateArea();
diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs
index b33e4bd3..143e5bf7 100644
--- a/OpenNest.IO/NestWriter.cs
+++ b/OpenNest.IO/NestWriter.cs
@@ -513,7 +513,8 @@ namespace OpenNest.IO
var dto = EntitySerializer.ToDto(
drawing.SourceEntities,
- drawing.SuppressedEntityIds
+ drawing.SuppressedEntityIds,
+ drawing.EntityRoles
);
var json = JsonSerializer.Serialize(dto, JsonOptions);
diff --git a/OpenNest.Tests/IO/EntityRoleTests.cs b/OpenNest.Tests/IO/EntityRoleTests.cs
new file mode 100644
index 00000000..04bfdab9
--- /dev/null
+++ b/OpenNest.Tests/IO/EntityRoleTests.cs
@@ -0,0 +1,186 @@
+using System.IO.Compression;
+using OpenNest.CNC;
+using OpenNest.Converters;
+using OpenNest.Geometry;
+using OpenNest.IO;
+using static OpenNest.IO.NestFormat;
+
+namespace OpenNest.Tests.IO;
+
+///
+/// Per-entity role overrides from the CAD converter: an entity can be cut, etched or kept for
+/// display only regardless of its DXF layer, and the choice survives a save.
+///
+public class EntityRoleTests
+{
+ private readonly Layer zero = new("0") { IsVisible = true };
+ private readonly Layer etchLayer = new("ETCH") { IsVisible = true };
+ private readonly List outline;
+ private readonly Circle hole;
+ private readonly Line markOnEtchLayer;
+ private readonly Line reference;
+
+ public EntityRoleTests()
+ {
+ outline = new List
+ {
+ new Line(10, 5, 30, 5) { Layer = zero },
+ new Line(30, 5, 30, 25) { Layer = zero },
+ new Line(30, 25, 10, 25) { Layer = zero },
+ new Line(10, 25, 10, 5) { Layer = zero },
+ };
+ hole = new Circle(20, 15, 2) { Layer = zero };
+ markOnEtchLayer = new Line(12, 22, 18, 22) { Layer = etchLayer };
+ reference = new Line(12, 8, 18, 8) { Layer = zero };
+ }
+
+ private List All => outline.Concat(new Entity[] { hole, markOnEtchLayer, reference }).ToList();
+
+ private Dictionary Overrides => new()
+ {
+ [markOnEtchLayer.Id] = EntityRole.Cut,
+ [hole.Id] = EntityRole.Etch,
+ [reference.Id] = EntityRole.Display,
+ };
+
+ [Fact]
+ public void WithoutOverridesLayersDecideTheRole()
+ {
+ var drawing = Build(null);
+
+ Assert.Equal(LayerType.Scribe, LayerAt(drawing, new Vector(15, 22)));
+ Assert.Equal(LayerType.Cut, LayerAt(drawing, new Vector(22, 15)));
+ Assert.Equal(LayerType.Cut, LayerAt(drawing, new Vector(15, 8)));
+ Assert.Empty(drawing.EntityRoles);
+ Assert.Empty(EntityRoles.DisplayGeometry(drawing));
+ }
+
+ [Fact]
+ public void OverridesDecideTheProgramLayerAndDisplayOnlyStaysOut()
+ {
+ var drawing = Build(Overrides);
+
+ Assert.Equal(LayerType.Cut, LayerAt(drawing, new Vector(15, 22)));
+ Assert.Equal(LayerType.Scribe, LayerAt(drawing, new Vector(22, 15)));
+ Assert.Null(LayerAt(drawing, new Vector(15, 8)));
+ Assert.Equal(3, drawing.EntityRoles.Count);
+
+ // The source entities are untouched: the overrides live beside them.
+ Assert.Same(etchLayer, markOnEtchLayer.Layer);
+ Assert.Same(zero, hole.Layer);
+ }
+
+ [Fact]
+ public void DisplayGeometryIsInTheProgramFrame()
+ {
+ var drawing = Build(Overrides);
+
+ var display = Assert.IsType(Assert.Single(EntityRoles.DisplayGeometry(drawing)));
+ var offset = drawing.Source.Offset;
+
+ Assert.Equal(reference.Id, display.Id);
+ Assert.True(display.StartPoint.DistanceTo(reference.StartPoint - offset) < 1e-9);
+ Assert.True(display.EndPoint.DistanceTo(reference.EndPoint - offset) < 1e-9);
+ }
+
+ [Fact]
+ public void ExcludedDisplayGeometryIsNotShown()
+ {
+ reference.IsVisible = false;
+
+ var drawing = Build(Overrides);
+
+ Assert.Contains(reference.Id, drawing.SuppressedEntityIds);
+ Assert.Empty(EntityRoles.DisplayGeometry(drawing));
+ }
+
+ [Fact]
+ public void OverridesSurviveASaveAndAreNotUndoneByMarkRepair()
+ {
+ var drawing = Build(Overrides);
+ var text = NestWriter.GetProgramText(drawing.Program);
+
+ var loaded = Read(Save(drawing)).Drawings.Single();
+
+ Assert.Equal(Overrides.OrderBy(r => r.Key), loaded.EntityRoles.OrderBy(r => r.Key));
+ Assert.Equal(text, NestWriter.GetProgramText(loaded.Program));
+ Assert.Equal(LayerType.Cut, LayerAt(loaded, new Vector(15, 22)));
+ Assert.Equal(reference.Id, Assert.Single(EntityRoles.DisplayGeometry(loaded)).Id);
+ }
+
+ [Fact]
+ public void EntitySetWithoutOverridesHasNoRolesKey()
+ {
+ var bytes = Save(Build(null));
+
+ using var archive = new ZipArchive(new MemoryStream(bytes));
+ using var reader = new StreamReader(archive.GetEntry("entities/entities-1")!.Open());
+ var json = reader.ReadToEnd();
+
+ Assert.DoesNotContain("\"roles\"", json);
+ }
+
+ [Fact]
+ public void UnknownRolesAndIdsAreSkipped()
+ {
+ var id = Guid.NewGuid();
+ var dto = new EntitySetDto
+ {
+ Roles = new Dictionary
+ {
+ [id.ToString()] = "Display",
+ [Guid.NewGuid().ToString()] = "bevel",
+ ["not-a-guid"] = "etch",
+ },
+ };
+
+ var roles = EntitySerializer.RolesFromDto(dto);
+
+ Assert.Equal(EntityRole.Display, Assert.Single(roles).Value);
+ Assert.Equal(id, roles.Single().Key);
+ }
+
+ private Drawing Build(Dictionary? roles)
+ {
+ var entities = All;
+ var result = new CadImportResult
+ {
+ Entities = entities,
+ Bounds = entities.GetBoundingBox(),
+ Name = "roles",
+ };
+ return CadImporter.BuildDrawing(
+ result,
+ entities.Where(e => e.IsVisible).ToList(),
+ result.Bends,
+ 1,
+ null,
+ editedProgram: null,
+ entityRoles: roles);
+ }
+
+ // The program layer of the move through a source-frame point, or null when there is none.
+ private static LayerType? LayerAt(Drawing drawing, Vector sourcePoint)
+ {
+ var point = sourcePoint - drawing.Source.Offset;
+ var geometry = ConvertProgram.ToGeometry(drawing.Program)
+ .Where(e => e.Layer != SpecialLayers.Rapid)
+ .FirstOrDefault(e => e.ClosestPointTo(point).DistanceTo(point) < 1e-6);
+ if (geometry == null)
+ return null;
+ if (geometry.Layer == SpecialLayers.Scribe)
+ return LayerType.Scribe;
+ return geometry.Layer == SpecialLayers.Cut ? LayerType.Cut : (LayerType?)LayerType.Display;
+ }
+
+ private static byte[] Save(Drawing drawing)
+ {
+ var nest = new Nest { Name = "roles" };
+ nest.Drawings.Add(drawing);
+ using var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ return stream.ToArray();
+ }
+
+ private static Nest Read(byte[] bytes) => new NestReader(new MemoryStream(bytes)).Read();
+}
diff --git a/OpenNest/Controls/EntityView.Breakoffs.cs b/OpenNest/Controls/EntityView.Breakoffs.cs
index 8d1611ee..63ff3541 100644
--- a/OpenNest/Controls/EntityView.Breakoffs.cs
+++ b/OpenNest/Controls/EntityView.Breakoffs.cs
@@ -65,6 +65,10 @@ namespace OpenNest.Controls
draggedTab = HitTestTab(e.Location);
if (draggedTab == null)
draggedCutoutTab = HitTestCutoutTab(e.Location);
+ if (draggedTab == null && draggedCutoutTab == null)
+ BeginWindowSelect(e);
+ else
+ suppressNextClick = true; // A tab press or drag does not change the selection.
}
protected override void OnMouseUp(MouseEventArgs e)
@@ -72,6 +76,7 @@ namespace OpenNest.Controls
base.OnMouseUp(e);
if (e.Button != MouseButtons.Left)
return;
+ EndWindowSelect(e);
if (draggedCutoutTab is { } cutoutTab)
{
draggedCutoutTab = null;
diff --git a/OpenNest/Controls/EntityView.Selection.cs b/OpenNest/Controls/EntityView.Selection.cs
new file mode 100644
index 00000000..d7e8c711
--- /dev/null
+++ b/OpenNest/Controls/EntityView.Selection.cs
@@ -0,0 +1,356 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Linq;
+using System.Windows.Forms;
+using OpenNest.Converters;
+using OpenNest.Geometry;
+
+namespace OpenNest.Controls
+{
+ public enum EntityAction
+ {
+ Cut,
+ Etch,
+ Display,
+ Exclude,
+ Include,
+ }
+
+ public sealed class EntityActionEventArgs : EventArgs
+ {
+ public EntityActionEventArgs(EntityAction action, IReadOnlyList entities)
+ {
+ Action = action;
+ Entities = entities;
+ }
+
+ public EntityAction Action { get; }
+ public IReadOnlyList Entities { get; }
+ }
+
+ public partial class EntityView
+ {
+ private const int WindowSelectThreshold = 4;
+ private const int ArcSegments = 64;
+
+ private readonly HashSet selectedEntities = new HashSet();
+ private Point? windowStart;
+ private Point windowEnd;
+ private bool isWindowSelecting;
+ private bool suppressNextClick;
+
+ /// Role overrides by entity ID, for drawing; entities not listed keep their layer's role.
+ public IReadOnlyDictionary EntityRoles { get; set; }
+
+ ///
+ /// IDs of entities the user excluded one by one. They are drawn faintly so they can be
+ /// selected and included again; entities hidden by a layer, color or line type filter are not.
+ ///
+ public IReadOnlySet ExcludedIds { get; set; }
+
+ public IReadOnlyCollection SelectedEntities => selectedEntities;
+
+ public event EventHandler EntityActionRequested;
+
+ public void ClearSelection()
+ {
+ if (selectedEntities.Count == 0)
+ return;
+ selectedEntities.Clear();
+ Invalidate();
+ }
+
+ private EntityRole RoleOf(Entity entity) => Converters.EntityRoles.Of(entity, EntityRoles);
+
+ private bool IsExcluded(Entity entity) => ExcludedIds != null && ExcludedIds.Contains(entity.Id);
+
+ // Entities the user can select: everything drawn, plus excluded ones (drawn faintly).
+ private bool IsSelectable(Entity entity) =>
+ entity is Line or Arc or Circle
+ && entity.Layer is { IsVisible: true }
+ && (entity.IsVisible || IsExcluded(entity));
+
+ private void BeginWindowSelect(MouseEventArgs e)
+ {
+ windowStart = e.Location;
+ windowEnd = e.Location;
+ isWindowSelecting = false;
+ }
+
+ private void HandleSelectionMouseMove(MouseEventArgs e)
+ {
+ if (windowStart is not { } start || (e.Button & MouseButtons.Left) == 0)
+ return;
+ windowEnd = e.Location;
+ if (!isWindowSelecting
+ && (System.Math.Abs(e.X - start.X) > WindowSelectThreshold
+ || System.Math.Abs(e.Y - start.Y) > WindowSelectThreshold))
+ isWindowSelecting = true;
+ if (isWindowSelecting)
+ Invalidate();
+ }
+
+ private void EndWindowSelect(MouseEventArgs e)
+ {
+ var start = windowStart;
+ windowStart = null;
+ if (!isWindowSelecting || start == null)
+ return;
+ isWindowSelecting = false;
+ suppressNextClick = true;
+
+ var a = PointControlToWorld(start.Value);
+ var b = PointControlToWorld(e.Location);
+ var rect = new Box(
+ System.Math.Min(a.X, b.X),
+ System.Math.Min(a.Y, b.Y),
+ System.Math.Abs(a.X - b.X),
+ System.Math.Abs(a.Y - b.Y));
+
+ // Dragging right selects what lies inside the window; dragging left also selects
+ // what the window crosses.
+ var crossing = e.X < start.Value.X;
+ var hits = Entities.Where(en => IsSelectable(en)
+ && (crossing ? Crosses(en, rect) : rect.Contains(en.BoundingBox)));
+
+ if ((ModifierKeys & (Keys.Control | Keys.Shift)) == 0)
+ selectedEntities.Clear();
+ foreach (var entity in hits)
+ selectedEntities.Add(entity);
+ Invalidate();
+ }
+
+ /// Handles a click for selection; returns true when it was consumed.
+ private bool HandleSelectionClick(MouseEventArgs e)
+ {
+ if (suppressNextClick)
+ {
+ suppressNextClick = false;
+ return e.Button == MouseButtons.Left;
+ }
+ if (IsPicking)
+ return false;
+
+ var hit = HitTestEntity(e.Location);
+ var additive = (ModifierKeys & (Keys.Control | Keys.Shift)) != 0;
+
+ if (e.Button == MouseButtons.Left)
+ {
+ if (hit == null)
+ {
+ if (!additive)
+ ClearSelection();
+ return true;
+ }
+ if (!additive)
+ selectedEntities.Clear();
+ if (additive && selectedEntities.Contains(hit))
+ selectedEntities.Remove(hit);
+ else
+ selectedEntities.Add(hit);
+ Invalidate();
+ return true;
+ }
+
+ if (e.Button == MouseButtons.Right)
+ {
+ if (hit != null && !selectedEntities.Contains(hit))
+ {
+ if (!additive)
+ selectedEntities.Clear();
+ selectedEntities.Add(hit);
+ Invalidate();
+ }
+ if (selectedEntities.Count == 0)
+ return false;
+ ShowEntityContextMenu(e.Location);
+ return true;
+ }
+
+ return false;
+ }
+
+ private void ShowEntityContextMenu(Point location)
+ {
+ var selection = selectedEntities.ToList();
+ var roles = selection.Select(RoleOf).Distinct().ToList();
+ var current = roles.Count == 1 ? roles[0] : (EntityRole?)null;
+ var noun = selection.Count == 1 ? "entity" : $"{selection.Count} entities";
+
+ var menu = new ContextMenuStrip();
+ menu.Items.Add(new ToolStripLabel(noun) { ForeColor = SystemColors.GrayText });
+ AddRoleItem(menu, "Cut", EntityAction.Cut, current == EntityRole.Cut, selection);
+ AddRoleItem(menu, "Etch", EntityAction.Etch, current == EntityRole.Etch, selection);
+ AddRoleItem(menu, "Display Only", EntityAction.Display, current == EntityRole.Display, selection);
+ menu.Items.Add(new ToolStripSeparator());
+
+ if (selection.Any(en => !IsExcluded(en)))
+ {
+ var exclude = new ToolStripMenuItem("Exclude") { ShortcutKeyDisplayString = "Del" };
+ exclude.Click += (s, e) => RaiseEntityAction(EntityAction.Exclude, selection);
+ menu.Items.Add(exclude);
+ }
+ if (selection.Any(IsExcluded))
+ {
+ var include = new ToolStripMenuItem("Include");
+ include.Click += (s, e) => RaiseEntityAction(EntityAction.Include, selection);
+ menu.Items.Add(include);
+ }
+
+ menu.Closed += (s, e) => BeginInvoke(new Action(menu.Dispose));
+ menu.Show(this, location);
+ }
+
+ private void AddRoleItem(ContextMenuStrip menu, string text, EntityAction action, bool isChecked,
+ List selection)
+ {
+ var item = new ToolStripMenuItem(text) { Checked = isChecked };
+ item.Click += (s, e) => RaiseEntityAction(action, selection);
+ menu.Items.Add(item);
+ }
+
+ private void RaiseEntityAction(EntityAction action, IReadOnlyList entities)
+ {
+ EntityActionRequested?.Invoke(this, new EntityActionEventArgs(action, entities));
+ Invalidate();
+ }
+
+ private bool HandleSelectionKey(KeyEventArgs e)
+ {
+ if (IsPicking)
+ return false;
+ if (e.KeyCode == Keys.Escape && selectedEntities.Count > 0)
+ {
+ ClearSelection();
+ return true;
+ }
+ if (e.KeyCode == Keys.Delete && selectedEntities.Count > 0)
+ {
+ RaiseEntityAction(EntityAction.Exclude, selectedEntities.ToList());
+ return true;
+ }
+ return false;
+ }
+
+ private Entity HitTestEntity(Point controlPoint)
+ {
+ var worldPoint = PointControlToWorld(controlPoint);
+ var tolerance = LengthGuiToWorld(6);
+ Entity best = null;
+ var bestDistance = double.MaxValue;
+
+ foreach (var entity in Entities)
+ {
+ if (!IsSelectable(entity))
+ continue;
+ var distance = worldPoint.DistanceTo(entity.ClosestPointTo(worldPoint));
+ if (distance < tolerance && distance < bestDistance)
+ {
+ best = entity;
+ bestDistance = distance;
+ }
+ }
+
+ return best;
+ }
+
+ private void DrawSelection(Graphics g)
+ {
+ if (selectedEntities.Count > 0)
+ {
+ using var selectedPen = new Pen(Color.FromArgb(255, 200, 60), 2f);
+ foreach (var entity in selectedEntities)
+ DrawEntityShape(g, entity, selectedPen);
+ }
+
+ if (!isWindowSelecting || windowStart is not { } start)
+ return;
+
+ var crossing = windowEnd.X < start.X;
+ var x = System.Math.Min(start.X, windowEnd.X) - origin.X;
+ var y = System.Math.Min(start.Y, windowEnd.Y) - origin.Y;
+ var w = System.Math.Abs(windowEnd.X - start.X);
+ var h = System.Math.Abs(windowEnd.Y - start.Y);
+ var tint = crossing ? Color.FromArgb(80, 200, 120) : Color.FromArgb(80, 140, 230);
+ using var fill = new SolidBrush(Color.FromArgb(40, tint));
+ using var border = new Pen(tint) { DashStyle = crossing ? DashStyle.Dash : DashStyle.Solid };
+ g.FillRectangle(fill, x, y, w, h);
+ g.DrawRectangle(border, x, y, w, h);
+ }
+
+ private static bool Crosses(Entity entity, Box rect)
+ {
+ var points = Polyline(entity);
+ for (var i = 0; i < points.Count; i++)
+ {
+ if (rect.Contains(points[i]))
+ return true;
+ if (i > 0 && SegmentCrossesBox(points[i - 1], points[i], rect))
+ return true;
+ }
+ return false;
+ }
+
+ private static List Polyline(Entity entity)
+ {
+ switch (entity)
+ {
+ case Line line:
+ return new List { line.StartPoint, line.EndPoint };
+ case Arc arc:
+ {
+ var sweep = arc.SweepAngle();
+ var sign = arc.IsReversed ? -1.0 : 1.0;
+ return Enumerable.Range(0, ArcSegments + 1)
+ .Select(i => PointOnCircle(arc.Center, arc.Radius, arc.StartAngle + sign * sweep * i / ArcSegments))
+ .ToList();
+ }
+ case Circle circle:
+ return Enumerable.Range(0, ArcSegments + 1)
+ .Select(i => PointOnCircle(circle.Center, circle.Radius, 2 * System.Math.PI * i / ArcSegments))
+ .ToList();
+ default:
+ return new List();
+ }
+ }
+
+ private static Vector PointOnCircle(Vector center, double radius, double angle) =>
+ new Vector(center.X + radius * System.Math.Cos(angle), center.Y + radius * System.Math.Sin(angle));
+
+ private static bool SegmentCrossesBox(Vector a, Vector b, Box rect)
+ {
+ var corners = new[]
+ {
+ new Vector(rect.Left, rect.Bottom),
+ new Vector(rect.Right, rect.Bottom),
+ new Vector(rect.Right, rect.Top),
+ new Vector(rect.Left, rect.Top),
+ };
+ for (var i = 0; i < 4; i++)
+ {
+ if (SegmentsIntersect(a, b, corners[i], corners[(i + 1) % 4]))
+ return true;
+ }
+ return false;
+ }
+
+ private static bool SegmentsIntersect(Vector p1, Vector p2, Vector q1, Vector q2)
+ {
+ static double Cross(Vector o, Vector a, Vector b) =>
+ (a.X - o.X) * (b.Y - o.Y) - (a.Y - o.Y) * (b.X - o.X);
+
+ var d1 = Cross(q1, q2, p1);
+ var d2 = Cross(q1, q2, p2);
+ var d3 = Cross(p1, p2, q1);
+ var d4 = Cross(p1, p2, q2);
+ // The extents test rejects collinear segments that do not overlap.
+ return d1 * d2 <= 0 && d3 * d4 <= 0
+ && System.Math.Max(p1.X, p2.X) >= System.Math.Min(q1.X, q2.X)
+ && System.Math.Max(q1.X, q2.X) >= System.Math.Min(p1.X, p2.X)
+ && System.Math.Max(p1.Y, p2.Y) >= System.Math.Min(q1.Y, q2.Y)
+ && System.Math.Max(q1.Y, q2.Y) >= System.Math.Min(p1.Y, p2.Y);
+ }
+ }
+}
diff --git a/OpenNest/Controls/EntityView.cs b/OpenNest/Controls/EntityView.cs
index 6a771ee5..98cbab37 100644
--- a/OpenNest/Controls/EntityView.cs
+++ b/OpenNest/Controls/EntityView.cs
@@ -88,8 +88,13 @@ namespace OpenNest.Controls
{
var text = HitTestText(e.Location);
if (text != null)
+ {
ShowTextContextMenu(text, e.Location);
+ return;
+ }
}
+
+ HandleSelectionClick(e);
}
protected override void OnPaint(PaintEventArgs e)
@@ -113,10 +118,26 @@ namespace OpenNest.Controls
}
}
+ using (var excludedPen = new Pen(Color.FromArgb(90, 150, 150, 150)) { DashPattern = new float[] { 2, 4 } })
+ {
+ foreach (var entity in Entities)
+ {
+ if (IsExcluded(entity) && !entity.IsVisible && entity.Layer.IsVisible)
+ DrawEntityShape(e.Graphics, entity, excludedPen);
+ }
+ }
+
+ using var displayPen = new Pen(DisplayRoleColor) { DashPattern = new float[] { 6, 3 } };
foreach (var entity in Entities)
{
- if (IsEtchLayer(entity.Layer))
+ var role = RoleOf(entity);
+ if (role == Converters.EntityRole.Etch)
continue;
+ if (role == Converters.EntityRole.Display)
+ {
+ DrawEntity(e.Graphics, entity, displayPen);
+ continue;
+ }
var isHighlighted =
simplifierHighlightSet != null && simplifierHighlightSet.Contains(entity);
var pen = isHighlighted
@@ -129,14 +150,19 @@ namespace OpenNest.Controls
DrawBreakoffs(e.Graphics);
DrawTabbedCutouts(e.Graphics);
+ // Etch on top. Entities etched by override rather than by their layer take the etch
+ // color so the change shows.
foreach (var entity in Entities)
{
- if (!IsEtchLayer(entity.Layer))
+ if (RoleOf(entity) != Converters.EntityRole.Etch)
continue;
- var pen = GetEntityPen(entity.Color);
+ var pen = Converters.EntityRoles.Default(entity) == Converters.EntityRole.Etch
+ ? GetEntityPen(entity.Color)
+ : GetEntityPen(EtchRoleColor);
DrawEntity(e.Graphics, entity, pen);
}
+ DrawSelection(e.Graphics);
DrawTexts(e.Graphics);
if (ShowEntityLabels)
@@ -206,6 +232,7 @@ namespace OpenNest.Controls
LastPoint = CurrentPoint;
CurrentPoint = PointControlToWorld(e.Location);
HandleBreakoffMouseMove(e);
+ HandleSelectionMouseMove(e);
}
lastPoint = e.Location;
@@ -228,6 +255,9 @@ namespace OpenNest.Controls
if (e.KeyCode == Keys.F)
ZoomToFit();
+ if (HandleSelectionKey(e))
+ return;
+
if (IsPicking && e.KeyCode == Keys.Escape)
{
IsPickingBendLine = false;
@@ -268,6 +298,9 @@ namespace OpenNest.Controls
penCache.Clear();
}
+ private static readonly Color EtchRoleColor = Color.Magenta;
+ private static readonly Color DisplayRoleColor = Color.FromArgb(120, 200, 220);
+
private static bool IsEtchLayer(Layer layer) =>
string.Equals(layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase);
@@ -494,6 +527,11 @@ namespace OpenNest.Controls
if (!e.Layer.IsVisible || !e.IsVisible)
return;
+ DrawEntityShape(g, e, pen);
+ }
+
+ private void DrawEntityShape(Graphics g, Entity e, Pen pen)
+ {
switch (e.Type)
{
case EntityType.Arc:
diff --git a/OpenNest/Controls/FileListControl.cs b/OpenNest/Controls/FileListControl.cs
index a80379c7..16277604 100644
--- a/OpenNest/Controls/FileListControl.cs
+++ b/OpenNest/Controls/FileListControl.cs
@@ -22,6 +22,12 @@ namespace OpenNest.Controls
public List Breakoffs { get; set; } = new();
public List TabbedCutouts { get; set; } = new();
public HashSet SuppressedEntityIds { get; set; }
+
+ /// Entities the user excluded one by one; they stay excluded whatever the filters show.
+ public HashSet ExcludedEntityIds { get; set; } = new();
+
+ /// Role overrides by entity ID; entities not listed keep their layer's role.
+ public Dictionary EntityRoles { get; set; } = new();
public Box Bounds { get; set; }
public int EntityCount { get; set; }
public List Texts { get; set; } = new();
diff --git a/OpenNest/Forms/CadConverterForm.cs b/OpenNest/Forms/CadConverterForm.cs
index fe9cace7..0839e593 100644
--- a/OpenNest/Forms/CadConverterForm.cs
+++ b/OpenNest/Forms/CadConverterForm.cs
@@ -67,6 +67,7 @@ namespace OpenNest.Forms
entityView1.LinePicked += OnLinePicked;
entityView1.PickCancelled += OnPickCancelled;
entityView1.TextConvertRequested += OnTextConvertRequested;
+ entityView1.EntityActionRequested += OnEntityActionRequested;
btnSplit.Click += OnSplitClicked;
numQuantity.ValueChanged += OnQuantityChanged;
txtCustomer.TextChanged += OnCustomerChanged;
@@ -204,6 +205,10 @@ namespace OpenNest.Forms
item.Entities.ForEach(e => e.Layer.IsVisible = true);
ReHidePromotedEntities(item.Bends);
ReHideSuppressedEntities(item);
+ ReHideExcludedEntities(item);
+ entityView1.ClearSelection();
+ entityView1.EntityRoles = item.EntityRoles;
+ entityView1.ExcludedIds = item.ExcludedEntityIds;
filterPanel.LoadItem(item.Entities, item.Bends);
RefreshBreakoffs(item);
@@ -286,6 +291,7 @@ namespace OpenNest.Forms
return;
filterPanel.ApplyFilters(item.Entities);
+ ReHideExcludedEntities(item);
ReHidePromotedEntities(item.Bends);
SyncSuppressedState(item);
RefreshBreakoffs(item);
@@ -309,7 +315,7 @@ namespace OpenNest.Forms
return;
}
- var entities = item.Entities.Where(en => en.Layer.IsVisible && en.IsVisible).ToList();
+ var entities = VisibleEntities(item);
if (entities.Count == 0)
{
programEditor.Clear();
@@ -404,7 +410,7 @@ namespace OpenNest.Forms
if (item == null)
return;
- var entities = item.Entities.Where(en => en.Layer.IsVisible && en.IsVisible).ToList();
+ var entities = VisibleEntities(item);
if (entities.Count == 0)
return;
@@ -545,8 +551,10 @@ namespace OpenNest.Forms
filterPanel.SetTabbedCutoutPickMode(false);
}
+ // The entities the program is built from: shown, with role overrides applied and
+ // display-only entities left out.
private static List VisibleEntities(FileListItem item) =>
- item.Entities.Where(en => en.Layer.IsVisible && en.IsVisible).ToList();
+ EntityRoles.ForProgram(item.Entities.Where(en => en.Layer.IsVisible && en.IsVisible), item.EntityRoles);
private void RefreshBreakoffs(FileListItem item)
{
@@ -657,6 +665,16 @@ namespace OpenNest.Forms
foreach (var id in cutout.EdgeIds.ToList())
if (split.TryGetValue(id, out var pieces))
cutout.EdgeIds.AddRange(pieces.Where(p => p != id));
+ foreach (var (id, pieces) in split)
+ {
+ foreach (var piece in pieces.Where(p => p != id))
+ {
+ if (item.ExcludedEntityIds.Contains(id))
+ item.ExcludedEntityIds.Add(piece);
+ if (item.EntityRoles.TryGetValue(id, out var role))
+ item.EntityRoles[piece] = role;
+ }
+ }
item.EntityCount = item.Entities.Count;
entityView1.Entities.Clear();
@@ -1249,6 +1267,8 @@ namespace OpenNest.Forms
drawing.SuppressedEntityIds.Count > 0
? new HashSet(drawing.SuppressedEntityIds)
: null,
+ ExcludedEntityIds = IndividuallySuppressed(entities, drawing.SuppressedEntityIds),
+ EntityRoles = new Dictionary(drawing.EntityRoles),
Bounds = bounds,
EntityCount = entities.Count,
};
@@ -1269,7 +1289,7 @@ namespace OpenNest.Forms
{
var visible = item.Entities.Where(e => e.Layer.IsVisible && e.IsVisible).ToList();
- if (visible.Count == 0)
+ if (EntityRoles.ForProgram(visible, item.EntityRoles).Count == 0)
continue;
// Rebuild a CadImportResult from the FileListItem's current state so
@@ -1296,7 +1316,8 @@ namespace OpenNest.Forms
item.Customer,
editedProgram,
item.Breakoffs,
- item.TabbedCutouts
+ item.TabbedCutouts,
+ item.EntityRoles
);
drawings.Add(drawing);
@@ -1343,6 +1364,73 @@ namespace OpenNest.Forms
}
}
+ private static void ReHideExcludedEntities(FileListItem item)
+ {
+ foreach (var entity in item.Entities)
+ {
+ if (item.ExcludedEntityIds.Contains(entity.Id))
+ entity.IsVisible = false;
+ }
+ }
+
+ // Suppressed entities that are not explained by a wholly hidden layer were excluded one by
+ // one (or by a color/line type filter, which is not restored); keep them excluded so a
+ // filter change does not bring them back.
+ private static HashSet IndividuallySuppressed(List entities, HashSet suppressed)
+ {
+ var result = new HashSet();
+ if (suppressed.Count == 0)
+ return result;
+ foreach (var layer in entities.GroupBy(e => e.Layer?.Name))
+ {
+ if (layer.All(e => suppressed.Contains(e.Id)))
+ continue;
+ result.UnionWith(layer.Where(e => suppressed.Contains(e.Id)).Select(e => e.Id));
+ }
+ return result;
+ }
+
+ private void OnEntityActionRequested(object sender, EntityActionEventArgs e)
+ {
+ var item = CurrentItem;
+ if (item == null || e.Entities.Count == 0)
+ return;
+
+ switch (e.Action)
+ {
+ case EntityAction.Exclude:
+ foreach (var entity in e.Entities)
+ item.ExcludedEntityIds.Add(entity.Id);
+ entityView1.ClearSelection();
+ break;
+
+ case EntityAction.Include:
+ foreach (var entity in e.Entities)
+ item.ExcludedEntityIds.Remove(entity.Id);
+ break;
+
+ default:
+ var role = e.Action switch
+ {
+ EntityAction.Etch => EntityRole.Etch,
+ EntityAction.Display => EntityRole.Display,
+ _ => EntityRole.Cut,
+ };
+ foreach (var entity in e.Entities)
+ {
+ item.ExcludedEntityIds.Remove(entity.Id);
+ if (role == EntityRoles.Default(entity))
+ item.EntityRoles.Remove(entity.Id);
+ else
+ item.EntityRoles[entity.Id] = role;
+ }
+ break;
+ }
+
+ OnFilterChanged(this, EventArgs.Empty);
+ entityView1.Invalidate();
+ }
+
private static void SyncSuppressedState(FileListItem item)
{
var bendSources = new HashSet(
diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs
index b1e9c516..d7b6c887 100644
--- a/OpenNest/LayoutPart.cs
+++ b/OpenNest/LayoutPart.cs
@@ -60,6 +60,9 @@ namespace OpenNest
public GraphicsPath EtchPath { get; private set; }
+ /// The drawing's display-only geometry; null when it has none. Never material or cut.
+ public GraphicsPath DisplayPath { get; private set; }
+
// Etch-only parts have no material path, but their visible closed mark must
// remain hit-testable without treating scribe geometry as a cut or fill.
internal GraphicsPath SelectionPath => Path?.PointCount > 0 ? MaterialPath : EtchPath;
@@ -104,6 +107,7 @@ namespace OpenNest
? new SolidBrush(colorScheme.SelectedPartColor) : null;
g.FillPath(muted ? Brushes.Gainsboro : schemeSelectionBrush ?? (IsSelected ? selectedBrush : brush),
MaterialPath);
+ DrawDisplay(g, muted);
DrawEtch(g, muted);
// Keep real cuts visible even where an etch overlaps them.
g.DrawPath(muted ? Pens.Gray : IsSelected && colorScheme.PartOutlineColor.IsEmpty ? selectedPen : pen, Path);
@@ -147,6 +151,8 @@ namespace OpenNest
LeadInPath = null;
EtchPath?.Dispose();
EtchPath = null;
+ DisplayPath?.Dispose();
+ DisplayPath = null;
OffsetPath?.Dispose();
OffsetPath = null;
}
@@ -172,6 +178,37 @@ namespace OpenNest
g.DrawPath(etchPen, EtchPath);
}
+ private void DrawDisplay(Graphics g, bool muted)
+ {
+ if (DisplayPath == null)
+ return;
+ using var displayPen = new Pen(muted ? Color.Gray : Color.FromArgb(70, 110, 140))
+ {
+ DashPattern = new float[] { 6, 3 },
+ };
+ g.DrawPath(displayPen, DisplayPath);
+ }
+
+ // Display-only entities are kept in the drawing's program frame before rotation, so they
+ // follow the part by its cumulative rotation about the program origin, then its location.
+ private GraphicsPath BuildDisplayPath(Matrix matrix)
+ {
+ var entities = EntityRoles.DisplayGeometry(BasePart.BaseDrawing);
+ if (entities.Count == 0)
+ return null;
+
+ var path = new GraphicsPath();
+ foreach (var entity in entities)
+ {
+ entity.Rotate(BasePart.Rotation);
+ entity.Offset(BasePart.Location);
+ using var figure = new Shape { Entities = new List { entity } }.GetGraphicsPath();
+ path.AddPath(figure, false);
+ }
+ path.Transform(matrix);
+ return path;
+ }
+
private Vector ComputeLabelPoint()
{
var entities = ConvertProgram.ToGeometry(BasePart.BaseDrawing.Program);
@@ -225,6 +262,8 @@ namespace OpenNest
}
Path = cutPath;
EtchPath = etchPath;
+ DisplayPath?.Dispose();
+ DisplayPath = BuildDisplayPath(plateView.Matrix);
LeadInPath = BasePart.HasManualLeadIns ? leadPath : null;
if (!BasePart.HasManualLeadIns)
leadPath.Dispose();
@@ -235,6 +274,9 @@ namespace OpenNest
if (LeadInPath?.PointCount > 0)
bounds = cutPath.PointCount > 0 || etchPath.PointCount > 0
? RectangleF.Union(bounds, LeadInPath.GetBounds()) : LeadInPath.GetBounds();
+ if (DisplayPath != null)
+ bounds = cutPath.PointCount > 0 || etchPath.PointCount > 0
+ ? RectangleF.Union(bounds, DisplayPath.GetBounds()) : DisplayPath.GetBounds();
// Include screen-space stroke width, even for a horizontal or vertical mark.
bounds.Inflate(1, 1);
DisplayBounds = bounds;
diff --git a/README.md b/README.md
index 0f1ce193..faa9f8d5 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ dotnet run --project OpenNest/OpenNest.csproj # desktop app (W
### Quick start
1. File > New Nest
-2. Import DXFs via the CAD Converter (layer/color filtering, bend detection, G-code preview) or create built-in shapes
+2. Import DXFs via the CAD Converter (layer/color filtering, per-entity exclude and cut/etch/display-only roles, bend detection, G-code preview) or create built-in shapes
3. Define plate size, material, quadrant, spacing
4. Fill — the engine arranges parts
5. Optionally add cut-off lines, apply Part Sequencing to order crossing cut-offs before their parts, then save `.nest`, export DXF, or post-process to G-code
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
index 0d5c5b06..c25e48d3 100644
--- a/docs/nest-file-format.md
+++ b/docs/nest-file-format.md
@@ -165,7 +165,7 @@ fixed archive timestamps and dates, and no best-fit entries, so equal content ha
equal bytes and SHA-256. The server stores each distinct package once by that hash.
A package holds geometry and geometry-bound data only: program, source entities and
-offset, suppressed entity IDs, bends, cut pins, breakoffs, tabbed cutouts and material
+offset, suppressed entity IDs, entity roles, bends, cut pins, breakoffs, tabbed cutouts and material
density. Fields
the server's library record owns are written as their defaults, so editing them never
changes the hash:
@@ -231,6 +231,22 @@ warning; the drawing and its program load unchanged, only the cutout stops being
Tab spans are saved as motions tagged `:TAB`. They bound material but are never cut;
versions without tabbed cutout support read them as ordinary cut motions.
+## Drawing entity roles
+
+`entities/entities-N` holds drawing N's source entities, the IDs of suppressed
+(excluded) ones in `suppressed`, and an optional `roles` object, omitted when empty,
+mapping source entity IDs to `"cut"`, `"etch"` or `"display"`. An entity without an
+entry keeps the role its layer implies: `ETCH`, `SCRIBE` and `ENGRAVE` etch, every
+other layer cuts. The stored program already has the roles applied: etched entities
+are `:SCRIBE` motions and display-only entities are not in the program at all, so
+posts, nesting, area and verification never see them; the desktop draws them with
+each placed part. Roles are geometry-bound data, so drawing packages carry them and
+they change the package hash. Unknown role names and malformed IDs are skipped (that
+entity keeps its layer role). Load-time etch repair only uses entities whose role is
+etch, so an etch-layer entity set to cut stays cut. Split pieces get no roles.
+Versions without role support ignore `roles`; they keep the program as saved but
+lose the overrides, and display-only geometry, on the next converter edit.
+
## Verification and limits
The automated tests cover save/reopen/re-save, rotated/manual/locked parts,