mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 21:01:05 -04:00
feat(cad): exclude entities and set cut/etch/display-only roles
Select entities in the CAD converter (click, or drag a window/crossing box) and right-click to exclude/include them or set them to cut, etch or display only, regardless of their DXF layer. Delete excludes the selection. Roles are stored per source entity on the drawing and persisted in the entity set as an optional `roles` map. Display-only geometry is kept out of the program, so posts, nesting and verification never see it; placed parts draw it as a dashed overlay. Load-time scribe repair skips etch-layer entities set to cut. Per-entity exclusions now survive filter changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
27a03b9caf
commit
d87302a189
16 files changed
+928
-20
No files matched your search
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
{
|
||||
/// <summary>What a drawing does with a source entity.</summary>
|
||||
public enum EntityRole
|
||||
{
|
||||
/// <summary>Cut through the material.</summary>
|
||||
Cut,
|
||||
|
||||
/// <summary>Marked on the surface (a Scribe pass): never material.</summary>
|
||||
Etch,
|
||||
|
||||
/// <summary>
|
||||
/// Shown with the part for reference only. It is kept out of the program, so it is never
|
||||
/// cut, posted, nested against or verified.
|
||||
/// </summary>
|
||||
Display,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Guid, EntityRole> overrides) =>
|
||||
overrides != null && overrides.TryGetValue(entity.Id, out var role) ? role : Default(entity);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static List<Entity> ForProgram(IEnumerable<Entity> entities, IReadOnlyDictionary<Guid, EntityRole> overrides)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A drawing's unsuppressed display-only entities as copies in its program frame (source
|
||||
/// coordinates shifted by -<c>Source.Offset</c>, before any program rotation).
|
||||
/// </summary>
|
||||
public static List<Entity> DisplayGeometry(Drawing drawing)
|
||||
{
|
||||
var result = new List<Entity>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,14 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public HashSet<Guid> SuppressedEntityIds { get; set; } = new HashSet<Guid>();
|
||||
|
||||
/// <summary>
|
||||
/// Role overrides for entities in <see cref="SourceEntities"/>; entities not listed keep the
|
||||
/// role their layer implies (see <see cref="Converters.EntityRoles"/>). <see cref="Program"/> already has
|
||||
/// them applied; display-only entities are not in it and come from
|
||||
/// <see cref="Converters.EntityRoles.DisplayGeometry"/>.
|
||||
/// </summary>
|
||||
public Dictionary<Guid, EntityRole> EntityRoles { get; set; } = new Dictionary<Guid, EntityRole>();
|
||||
|
||||
public double Area { get; protected set; }
|
||||
|
||||
public void UpdateArea()
|
||||
|
||||
@@ -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.
|
||||
/// </param>
|
||||
/// <param name="entityRoles">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public static Drawing BuildDrawing(
|
||||
CadImportResult result,
|
||||
IEnumerable<Entity> entities,
|
||||
@@ -148,10 +152,13 @@ namespace OpenNest.IO
|
||||
string customer,
|
||||
OpenNest.CNC.Program editedProgram,
|
||||
IReadOnlyList<Breakoff> breakoffs = null,
|
||||
IReadOnlyList<TabbedCutout> tabbedCutouts = null
|
||||
IReadOnlyList<TabbedCutout> tabbedCutouts = null,
|
||||
IReadOnlyDictionary<System.Guid, EntityRole> entityRoles = null
|
||||
)
|
||||
{
|
||||
var visible = entities as IList<Entity> ?? new List<Entity>(entities);
|
||||
var visible = entityRoles is { Count: > 0 }
|
||||
? EntityRoles.ForProgram(entities, entityRoles)
|
||||
: entities as IList<Entity> ?? new List<Entity>(entities);
|
||||
var bendList = bends as IList<Bend> ?? new List<Bend>(bends);
|
||||
|
||||
var cut = breakoffs is { Count: > 0 }
|
||||
@@ -201,6 +208,11 @@ namespace OpenNest.IO
|
||||
.Select(e => e.Id)
|
||||
);
|
||||
|
||||
var sourceIds = new HashSet<System.Guid>(drawing.SourceEntities.Select(e => e.Id));
|
||||
drawing.EntityRoles = entityRoles?
|
||||
.Where(r => sourceIds.Contains(r.Key))
|
||||
.ToDictionary(r => r.Key, r => r.Value) ?? new Dictionary<System.Guid, EntityRole>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<Entity> entities, HashSet<Guid> suppressed)
|
||||
public static EntitySetDto ToDto(
|
||||
List<Entity> entities,
|
||||
HashSet<Guid> suppressed,
|
||||
IReadOnlyDictionary<Guid, EntityRole> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Role overrides of <paramref name="dto"/>. Unknown role names and malformed IDs are
|
||||
/// skipped, so those entities keep their layer's role.
|
||||
/// </summary>
|
||||
public static Dictionary<Guid, EntityRole> RolesFromDto(EntitySetDto dto)
|
||||
{
|
||||
var roles = new Dictionary<Guid, EntityRole>();
|
||||
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)
|
||||
|
||||
@@ -552,6 +552,10 @@ namespace OpenNest.IO
|
||||
{
|
||||
public List<EntityDto> Entities { get; init; } = new();
|
||||
public List<string> Suppressed { get; init; } = new();
|
||||
|
||||
/// <summary>Role overrides by entity ID ("cut", "etch" or "display"); omitted when none.</summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public Dictionary<string, string> Roles { get; init; }
|
||||
}
|
||||
|
||||
public record EntityDto
|
||||
|
||||
@@ -186,11 +186,11 @@ namespace OpenNest.IO
|
||||
return reader.Read();
|
||||
}
|
||||
|
||||
private Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> ReadEntitySets(
|
||||
private Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed, Dictionary<Guid, EntityRole> roles)> ReadEntitySets(
|
||||
int count
|
||||
)
|
||||
{
|
||||
var result = new Dictionary<int, (List<Entity>, HashSet<Guid>)>();
|
||||
var result = new Dictionary<int, (List<Entity>, HashSet<Guid>, Dictionary<Guid, EntityRole>)>();
|
||||
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<EntitySetDto>(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<int, Drawing> BuildDrawings(
|
||||
NestDto dto,
|
||||
Dictionary<int, Program> programs,
|
||||
Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> entitySets
|
||||
Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed, Dictionary<Guid, EntityRole> roles)> entitySets
|
||||
)
|
||||
{
|
||||
var map = new Dictionary<int, Drawing>();
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class EntityRoleTests
|
||||
{
|
||||
private readonly Layer zero = new("0") { IsVisible = true };
|
||||
private readonly Layer etchLayer = new("ETCH") { IsVisible = true };
|
||||
private readonly List<Entity> outline;
|
||||
private readonly Circle hole;
|
||||
private readonly Line markOnEtchLayer;
|
||||
private readonly Line reference;
|
||||
|
||||
public EntityRoleTests()
|
||||
{
|
||||
outline = new List<Entity>
|
||||
{
|
||||
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<Entity> All => outline.Concat(new Entity[] { hole, markOnEtchLayer, reference }).ToList();
|
||||
|
||||
private Dictionary<Guid, EntityRole> 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<Line>(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<string, string>
|
||||
{
|
||||
[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<Guid, EntityRole>? 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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Entity> entities)
|
||||
{
|
||||
Action = action;
|
||||
Entities = entities;
|
||||
}
|
||||
|
||||
public EntityAction Action { get; }
|
||||
public IReadOnlyList<Entity> Entities { get; }
|
||||
}
|
||||
|
||||
public partial class EntityView
|
||||
{
|
||||
private const int WindowSelectThreshold = 4;
|
||||
private const int ArcSegments = 64;
|
||||
|
||||
private readonly HashSet<Entity> selectedEntities = new HashSet<Entity>();
|
||||
private Point? windowStart;
|
||||
private Point windowEnd;
|
||||
private bool isWindowSelecting;
|
||||
private bool suppressNextClick;
|
||||
|
||||
/// <summary>Role overrides by entity ID, for drawing; entities not listed keep their layer's role.</summary>
|
||||
public IReadOnlyDictionary<Guid, EntityRole> EntityRoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public IReadOnlySet<Guid> ExcludedIds { get; set; }
|
||||
|
||||
public IReadOnlyCollection<Entity> SelectedEntities => selectedEntities;
|
||||
|
||||
public event EventHandler<EntityActionEventArgs> 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();
|
||||
}
|
||||
|
||||
/// <summary>Handles a click for selection; returns true when it was consumed.</summary>
|
||||
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<Entity> 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<Entity> 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<Vector> Polyline(Entity entity)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Line line:
|
||||
return new List<Vector> { 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<Vector>();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -22,6 +22,12 @@ namespace OpenNest.Controls
|
||||
public List<Breakoffs.Breakoff> Breakoffs { get; set; } = new();
|
||||
public List<TabbedCutouts.TabbedCutout> TabbedCutouts { get; set; } = new();
|
||||
public HashSet<Guid> SuppressedEntityIds { get; set; }
|
||||
|
||||
/// <summary>Entities the user excluded one by one; they stay excluded whatever the filters show.</summary>
|
||||
public HashSet<Guid> ExcludedEntityIds { get; set; } = new();
|
||||
|
||||
/// <summary>Role overrides by entity ID; entities not listed keep their layer's role.</summary>
|
||||
public Dictionary<Guid, Converters.EntityRole> EntityRoles { get; set; } = new();
|
||||
public Box Bounds { get; set; }
|
||||
public int EntityCount { get; set; }
|
||||
public List<CadText> Texts { get; set; } = new();
|
||||
|
||||
@@ -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<Entity> 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<Guid>(drawing.SuppressedEntityIds)
|
||||
: null,
|
||||
ExcludedEntityIds = IndividuallySuppressed(entities, drawing.SuppressedEntityIds),
|
||||
EntityRoles = new Dictionary<Guid, EntityRole>(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<Guid> IndividuallySuppressed(List<Entity> entities, HashSet<Guid> suppressed)
|
||||
{
|
||||
var result = new HashSet<Guid>();
|
||||
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<Entity>(
|
||||
|
||||
@@ -60,6 +60,9 @@ namespace OpenNest
|
||||
|
||||
public GraphicsPath EtchPath { get; private set; }
|
||||
|
||||
/// <summary>The drawing's display-only geometry; null when it has none. Never material or cut.</summary>
|
||||
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> { 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user