Files
OpenNest/OpenNest.IO/CadImporter.cs
T
aj bc21382c5d fix(io): give re-imported unchanged DXFs the same entity ids
Entity ids came from a process-random sequence, so importing the same
DXF twice produced different source-entity ids and therefore a
different drawing library package (different SHA-256), and an importer
could not tell an unchanged file from a changed one.

CadImporter.Import now derives each entity id from the file's SHA-256
and the entity's position in the import. An unchanged file gives the
same ids and the same package bytes; any change to the file gives
entirely new ids, so suppression, roles and edge selections keyed by id
never move to a different entity.
2026-10-24 09:12:48 -04:00

343 lines
14 KiB
C#

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using OpenNest.Bending;
using OpenNest.Breakoffs;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO.Bending;
using OpenNest.Math;
using OpenNest.TabbedCutouts;
namespace OpenNest.IO
{
/// <summary>
/// Shared service that converts a CAD source file into a fully-populated
/// <see cref="Drawing"/>. Used by the UI, console, MCP, API, and training
/// tools so all code paths produce identical drawings.
/// </summary>
public static class CadImporter
{
/// <summary>
/// Load a DXF file, run bend detection, and return a mutable result
/// ready for interactive editing or direct conversion to a Drawing.
/// </summary>
public static CadImportResult Import(string path, CadImportOptions options = null)
{
options ??= CadImportOptions.Default;
var dxf = Dxf.Import(path, preserveRepairMarks: options.BendRepair != null);
var cleanup =
options.BendRepair == null
? dxf.Entities
: dxf.Entities.Where(e => !IsRepairMark(e)).ToList();
RemoveDuplicateArcs(cleanup);
RemoveZeroSweepArcs(cleanup);
if (options.BendRepair != null)
dxf.Entities.RemoveAll(e => !IsRepairMark(e) && !cleanup.Contains(e));
var bends = new List<Bend>();
if (options.DetectBends && dxf.Document != null)
{
bends =
options.BendDetectorName == null
? BendDetectorRegistry.AutoDetect(dxf.Document)
: BendDetectorRegistry
.GetByName(options.BendDetectorName)
?.DetectBends(dxf.Document)
?? new List<Bend>();
}
var repairReports = new List<BendRepairReport>();
if (options.BendRepair == null)
Bend.UpdateEtchEntities(dxf.Entities, bends);
else
{
// Unitless DXFs require the explicit caller declaration. Never override a conflicting header.
var headerUnits = (int)(dxf.Document?.Header.InsUnits ?? 0);
var requestedUnits =
options.BendRepair.DrawingUnits == BendRepairUnits.Inches ? 1 : 4;
if (headerUnits != 0 && headerUnits != requestedUnits)
repairReports = bends
.Select(
(b, i) =>
new BendRepairReport(
i,
"Skipped",
"DXF insertion units conflict with the declared repair units or are unsupported.",
b.StartPoint,
b.EndPoint,
b.StartPoint,
b.EndPoint
)
)
.ToList();
else
repairReports = BendRepair.Apply(dxf.Entities, bends, options.BendRepair);
}
AssignFileIds(path, dxf.Entities);
return new CadImportResult
{
Entities = dxf.Entities,
Bends = bends,
BendRepairReports = repairReports,
Bounds = dxf.Entities.GetBoundingBox(),
SourcePath = path,
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
Document = dxf.Document,
};
}
/// <summary>
/// Gives each imported entity an id derived from the file's bytes and the entity's
/// position in the import, so importing an unchanged file again yields the same ids
/// (and the same library package), while any change to the file yields entirely new
/// ids: ids key suppression, roles and edge selections, so one must never move to
/// another entity.
/// </summary>
private static void AssignFileIds(string path, IList<Entity> entities)
{
Span<byte> input = stackalloc byte[32 + 4];
Span<byte> digest = stackalloc byte[32];
using (var stream = File.OpenRead(path))
SHA256.HashData(stream, input[..32]);
for (var i = 0; i < entities.Count; i++)
{
BinaryPrimitives.WriteInt32LittleEndian(input[32..], i);
SHA256.HashData(input, digest);
// RFC 9562 version 8 (custom) layout.
digest[7] = (byte)((digest[7] & 0x0F) | 0x80);
digest[8] = (byte)((digest[8] & 0x3F) | 0x80);
entities[i].Id = new Guid(digest[..16]);
}
}
/// <summary>
/// Convenience for headless callers: Import a file and build a Drawing
/// in a single call, using all loaded entities and detected bends.
/// </summary>
public static Drawing ImportDrawing(string path, CadImportOptions options = null)
{
options ??= CadImportOptions.Default;
var result = Import(path, options);
return BuildDrawing(
result,
result.Entities,
result.Bends,
options.Quantity,
options.Customer,
editedProgram: null
);
}
/// <summary>
/// Build a fully-populated <see cref="Drawing"/> from an import result plus
/// the caller's current entity and bend state. UI callers pass the currently
/// visible subset; headless callers pass the full lists.
///
/// The produced drawing has:
/// - Program generated from the visible entities, with its first rapid moved
/// to the origin and the pierce location stored in Source.Offset
/// - SourceEntities containing all non-bend-source entities from the result
/// - SuppressedEntityIds containing entities whose layer or IsVisible is false
/// - Bends copied from the provided list
/// - Customer, Quantity, Source.Path from options / result
/// </summary>
/// <param name="result">Import result from <see cref="Import"/>.</param>
/// <param name="entities">
/// Entities to build the program from. Typically the currently visible subset.
/// </param>
/// <param name="bends">Bends to attach to the drawing.</param>
/// <param name="quantity">Required quantity.</param>
/// <param name="customer">Customer name, or null.</param>
/// <param name="editedProgram">
/// When non-null, replaces the generated program (used by the UI to honor
/// in-place G-code edits). Source.Offset is still populated from the
/// generated program so round-trips stay consistent.
/// </param>
/// <param name="breakoffs">
/// Breakoffs to merge into the program and keep on the drawing; ones that cannot be
/// resolved against <paramref name="entities"/> are kept but not applied.
/// </param>
/// <param name="tabbedCutouts">
/// 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,
IEnumerable<Bend> bends,
int quantity,
string customer,
OpenNest.CNC.Program editedProgram,
IReadOnlyList<Breakoff> breakoffs = null,
IReadOnlyList<TabbedCutout> tabbedCutouts = null,
IReadOnlyDictionary<System.Guid, EntityRole> entityRoles = null
)
{
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 }
? BreakoffBuilder.Apply(visible.ToList(), breakoffs, out _)
: visible;
if (tabbedCutouts is { Count: > 0 })
cut = TabbedCutoutBuilder.Apply(cut.ToList(), tabbedCutouts, out _);
var normalized = ShapeProfile.NormalizeEntities(cut);
var pgm = ConvertGeometry.ToProgram(normalized);
var offset = Vector.Zero;
if (
pgm != null
&& pgm.Codes.Count > 0
&& pgm[0].Type == OpenNest.CNC.CodeType.RapidMove
)
{
var rapid = (OpenNest.CNC.RapidMove)pgm[0];
offset = rapid.EndPoint;
pgm.Offset(-offset);
}
var drawing = new Drawing(result.Name)
{
Color = Drawing.GetNextColor(),
Customer = customer,
};
drawing.Source.Path = result.SourcePath;
drawing.Source.Offset = offset;
drawing.Quantity.Required = quantity;
drawing.Bends.AddRange(bendList);
drawing.Breakoffs = breakoffs?.Select(b => b.Clone()).ToList() ?? new List<Breakoff>();
drawing.TabbedCutouts = tabbedCutouts?.Select(c => c.Clone()).ToList() ?? new List<TabbedCutout>();
drawing.Program = editedProgram ?? pgm;
var bendSources = new HashSet<Entity>(
bendList.Where(b => b.SourceEntity != null).Select(b => b.SourceEntity)
);
drawing.SourceEntities = result.Entities.Where(e => !bendSources.Contains(e)).ToList();
drawing.SuppressedEntityIds = new HashSet<System.Guid>(
drawing
.SourceEntities.Where(e =>
!(e.Layer != null && e.Layer.IsVisible && e.IsVisible)
)
.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;
}
/// <summary>
/// The entities an editor shows for an existing drawing, in its source frame: the stored
/// source entities with their suppression applied to <see cref="Entity.IsVisible"/>, or
/// for older drawings without them, the program geometry moved back by
/// <c>Source.Offset</c> without rapids.
/// </summary>
public static List<Entity> EditableEntities(Drawing drawing)
{
if (drawing.SourceEntities != null)
{
// Use stored entities with stable GUIDs; apply suppression state
var entities = new List<Entity>(drawing.SourceEntities);
foreach (var entity in entities)
entity.IsVisible = !drawing.SuppressedEntityIds.Contains(entity.Id);
return entities;
}
var derived = 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 derived)
entity.Offset(drawing.Source.Offset);
}
// Remove rapid traversals — they aren't part of the cut geometry
derived.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
return derived;
}
/// <summary>
/// Applies an edited rebuild of <paramref name="existing"/> in place, so placed parts keep
/// their drawing reference. Geometry, source, customer, quantity and bends are taken from
/// <paramref name="edited"/>; the library link and cut pins stay, since pins live in the
/// source frame and are resolved against the new program when used.
/// </summary>
public static void ApplyEdit(Drawing existing, Drawing edited)
{
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;
existing.Bends.Clear();
existing.Bends.AddRange(edited.Bends);
existing.Breakoffs = edited.Breakoffs;
existing.TabbedCutouts = edited.TabbedCutouts;
}
private static bool IsRepairMark(Entity e) =>
string.Equals(e.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.Layer?.Name, "SCRIBE", System.StringComparison.OrdinalIgnoreCase);
internal static void RemoveZeroSweepArcs(List<Entity> entities)
{
entities.RemoveAll(e =>
e is Arc arc && arc.StartAngle.IsEqualTo(arc.EndAngle, Tolerance.ChainTolerance)
);
}
internal static void RemoveDuplicateArcs(List<Entity> entities)
{
var circles = entities.OfType<Circle>().ToList();
var arcs = entities.OfType<Arc>().ToList();
var arcsToRemove = new List<Arc>();
foreach (var arc in arcs)
{
foreach (var circle in circles)
{
if (arc.Layer?.Name != circle.Layer?.Name)
continue;
if (!arc.Center.DistanceTo(circle.Center).IsEqualTo(0))
continue;
if (!arc.Radius.IsEqualTo(circle.Radius))
continue;
arcsToRemove.Add(arc);
break;
}
}
foreach (var arc in arcsToRemove)
entities.Remove(arc);
}
}
}