Files
OpenNest/OpenNest.IO/CadImporter.cs
T
ajandClaude Opus 5.5 bbdd51fabc feat(cad-converter): show the source file's raw geometry as Original
Show Original drew nothing until Simplify ran, and then showed entities that
had already been through import cleanup. The importers now capture the
entities before any optimization and the overlay draws those from load, while
Simplify keeps starting from the cleaned entities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:07:06 -05:00

356 lines
15 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.Dimensions;
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 = Svg.IsSvg(path)
? Svg.Import(path, options)
: 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);
GeometryOptimizer.JoinFullCircles(cleanup);
if (options.BendRepair != null)
{
dxf.Entities.RemoveAll(e => !IsRepairMark(e) && !cleanup.Contains(e));
dxf.Entities.AddRange(cleanup.Where(e => !dxf.Entities.Contains(e)).ToList());
}
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,
RawEntities = dxf.RawEntities,
Bends = bends,
BendRepairReports = repairReports,
Bounds = dxf.Entities.GetBoundingBox(),
SourcePath = path,
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
Document = dxf.Document,
Warnings = dxf.Warnings,
};
}
/// <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>
/// <param name="dimensions">Dimensions to keep on the drawing; they never affect 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,
IReadOnlyList<DrawingDimension> dimensions = 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.Dimensions = dimensions?.Select(d => d.Clone()).ToList() ?? new List<DrawingDimension>();
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;
existing.Dimensions = edited.Dimensions;
}
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);
}
}
}