Compare commits

43 Commits

Author SHA1 Message Date
aj c25b6bc23a feat(ui): render DXF text annotations in CAD converter preview
Extract MText and TextEntity from the CadDocument during DXF import
and render them in the EntityView. Handles text alignment (left/center/
right via InsertPoint vs AlignmentPoint) and replaces AutoCAD control
codes (%%p → ±, %%d → °, %%c → ⌀). MText formatting codes are
stripped before display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 21:45:44 -04:00
aj 1c994718fb feat(io): add DWG file import support via ACadSharp DwgReader
ACadSharp already includes DwgReader, so this wires it up across the
entire import pipeline — Dxf.Import, CadConverter drag-drop, nest
import dialog, console CLI, BOM analyzer, and training data collector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 23:53:29 -04:00
aj 9d58e6fba8 fix(ui): stay on drawings tab after DXF import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 23:53:29 -04:00
aj 2bae5340f0 test: add nest invariance tests for fill count across import orientations
Verify that filling an L-shaped part produces consistent counts
regardless of the orientation it was imported at, and that all
placed parts stay within the plate work area.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:56 -04:00
aj 0b322817d7 fix(core): use chain tolerance for entity gap check to prevent spurious rapids
Ellipse-to-arc conversion creates tiny floating-point gaps (~0.00002")
between consecutive arc segments. ShapeBuilder chains these with
ChainTolerance (0.0001"), but ConvertGeometry checked gaps with Epsilon
(0.00001"). Gaps between these thresholds generated spurious rapid moves
that broke GraphicsPath figures, causing diagonal fill artifacts from
GDI+'s implicit figure closing.

Root cause fix: align ConvertGeometry's gap check with ShapeBuilder's
ChainTolerance so precision gaps are absorbed instead of generating rapids.

Defense-in-depth: GraphicsHelper no longer breaks figures at near-zero
rapids, protecting against any programs with residual tiny rapids.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 21:32:08 -04:00
aj e41f335c63 feat: remove duplicate arcs matching circles on same layer during DXF import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:44:54 -04:00
aj 0ab33af5d3 feat: add WeldEndpoints to ShapeBuilder for gap repair on import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 10:40:43 -04:00
aj e04c9381f3 feat: add IComparable<Box> and comparison operators to Box
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:36:23 -04:00
aj ceb9cc0b44 refactor: move Fraction from OpenNest.IO.Bom to OpenNest.Math
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:33:57 -04:00
aj 4cecaba83a fix(core): emit line instead of arc for near-zero sweep to avoid full-circle misinterpretation
Near-zero-sweep arcs with large radius (e.g. from ellipse converter) have
nearly-coincident start/end points. Downstream code (ConvertProgram, Program
BoundingBox) treats coincident start/end as a full 360° circle, inflating the
bounding box and rendering wrong geometry. Emit a LinearMove when sweep is
negligible — geometrically equivalent and avoids the ambiguity. Also fix the
ellipse converter to produce lines instead of degenerate arcs at the source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:50:38 -04:00
aj 4053f1f989 fix(core): arc bounding box inflated for near-zero sweep arcs
Arcs with sweep angles smaller than Tolerance.Epsilon were treated as
full circles by IsBetweenRad's shortcut check, causing UpdateBounds to
expand the bounding box to Center ± Radius. This made zoom-to-fit zoom
out far beyond the actual part extents.

Skip cardinal angle expansion when sweep is near-zero so the bounding
box uses only the arc's start/end points.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:22:20 -04:00
aj ca67b1bd29 fix(io): handle flipped OCS normal on DXF ellipse import
Ellipses with extrusion direction Z=-1 had their parametric direction
reversed, causing the curve to appear mirrored. Negate start/end
parameters when Normal.Z < 0 to correct the minor-axis traversal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:22:20 -04:00
aj 199095ee43 fix(engine): canonicalize PlaceBestFitPairs builds to match BestFitCache frame 2026-04-23 08:22:20 -04:00
aj eb493d501a feat(engine): wrap single-item Fill with canonicalize/un-rotate bookends 2026-04-23 08:22:20 -04:00
aj 6c98732117 feat(engine): BestFitCache operates in canonical frame; TryPlaceBestFitPair builds from canonical drawing 2026-04-23 08:22:20 -04:00
aj a2e9fd4d14 feat(engine): extract ML features from canonical drawing frame 2026-04-23 08:22:20 -04:00
aj d228b6b812 refactor(engine): share MBR between PartClassifier and CanonicalAngle 2026-04-23 08:22:20 -04:00
aj c634aecd4b docs(core): refresh SourceInfo.Angle doc now that setter wiring lands 2026-04-23 08:22:19 -04:00
aj 14b7c1cf32 feat(core): store Source.Angle; recompute when Program changes 2026-04-23 08:22:19 -04:00
aj 402af91af5 feat(engine): add CanonicalFrame helper for drawing-to-canonical rotation 2026-04-23 08:22:19 -04:00
aj 9a6b656e3c feat(core): add CanonicalAngle helper for MBR-aligning angle 2026-04-23 08:22:19 -04:00
aj d2f9597b0c refactor(fill): use native entity geometry for linear copy distance
Replaces PartBoundary polygon edges with PartGeometry.GetOffsetPerimeterEntities
(inflated Line/Arc entities) so arcs are handled exactly without the polygon
sampling error that previously required a bboxDim + PartSpacing clamp. Adds
bbox DirectionalGap / PerpendicularOverlap early-outs to skip pair checks
that can't produce a valid slide, and removes the now-unused PartBoundary
cache, GetPatternLines/GetOffsetPatternLines helpers, and ComputeCopyDistance
clamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:26:21 -04:00
aj c40dcf0e25 chore: remove unused debug logging to desktop
NfpSlideStrategy wrote to nfp-slide-debug.log on the Desktop on every
call. The console's SetUpLog created test-harness-logs/ next to input
files but nothing in the codebase wrote to Trace, so those files were
always empty. Drop both along with the --no-log flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:24:40 -04:00
aj 28653e3a9f feat(shapes): generate unique drawing names from parameters and add toolbar button
Shape library drawings now get descriptive names based on their
parameters (e.g. "Rectangle 12x6", "Circle 8 Dia") instead of generic
type names, preventing silent duplicates in the DrawingCollection
HashSet. Added a Shape Library button to the Drawings tab toolbar
and removed separators between toolbar buttons for a cleaner look.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:48:45 -04:00
aj 7c3246c6e7 fix(cutting): restrict tabs to external perimeter and clarify tab UI
Tabs were being applied to internal cutouts and circle holes, which is
incorrect — only the external perimeter should be tabbed. Restructured
the Tabs panel to use radio buttons ("Tab all parts" vs "Auto-tab by
smallest dimension") so the two modes are clearly mutually exclusive
instead of the confusing implicit override behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:55:30 -04:00
aj bd48f57ce0 feat(ui): distinct Dark palette and recolor drawings on scheme switch
- Replace Dark part colors with high-contrast neon/electric palette
- Recolor existing drawings in open nests when scheme changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:06:14 -04:00
aj a6ec21accc fix(ui): address code review issues in color scheme feature
- Sync PlateView.BackColor on repaint so live scheme switch updates background
- Guard FromHex against truncated hex strings (< 6 chars)
- Cache disk schemes to avoid re-reading Schemes/ folder on every access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:56:15 -04:00
aj 320cf40f41 feat(ui): ship Schemes folder for user-defined color scheme JSON
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:52:45 -04:00
aj 3beca10429 feat(ui): add color scheme picker to Options dialog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:52:00 -04:00
aj 8bea5dac6c feat(ui): apply active color scheme at startup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:50:03 -04:00
aj 12f8bbf8f5 feat(ui): add ActiveColorScheme user setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 21:49:31 -04:00
aj d15790b948 feat(ui): add ColorSchemeRegistry with Classic/Pastel/Dark built-ins
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:48:46 -04:00
aj d80f76e386 feat(ui): add ColorScheme.Name/PartColors instance props and JSON serializer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:46:51 -04:00
aj 07bce8699a refactor(core): make Drawing.PartColors mutable for scheme overrides
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 21:45:18 -04:00
aj 9b84508ff4 refactor(shapes): generalize OctagonShape to NgonShape
Parameterize side count so users can generate any regular n-gon
(n>=3). Width remains the inscribed-circle diameter, preserving n=8
behavior; circumradius derives as Width / (2*cos(pi/n)).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:42:02 -04:00
aj 6fdf0ad3c5 refactor(cnc): extract rapid enumeration into RapidEnumerator
Pulls the rapid-walk logic (sub-program unwrapping, first-pierce lookup,
incremental-vs-absolute handling, first-rapid skipping) out of
PlateRenderer.DrawRapids into a reusable RapidEnumerator in Core so it
can be unit-tested and reused outside the renderer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 12:49:04 -04:00
aj 4f7bfcc3ad Merge remote-tracking branch 'origin/master' 2026-04-15 12:46:40 -04:00
aj 3c53d6fecd fix(engine): default FillContext.Policy to avoid null-deref in ReportProgress
FillContext.ReportProgress dereferences Policy.Comparer, so any caller
that forgot to set Policy hit a NullReferenceException. Default to
FillPolicy(DefaultFillComparer) so tests and ad-hoc callers work without
boilerplate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:28:58 -04:00
aj e239967a7b feat(cincinnati): emit SubProgramCall features as M98 hole calls
When a feature is a single SubProgramCall, wrap the call with a G52
offset shift, emit M98 P<num>, reset G52, and add M47 between features.
Accepts an optional hole subprogram id map so the post can remap
drawing-local subprogram ids to machine subprogram numbers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:17:31 -04:00
aj 9d57d3875a fix(cnc): offset SubProgramCall positions in Program.Offset
Program.Offset only adjusted Motion codes, so subprogram calls kept
their original offsets after a part was translated. Apply the offset
to SubProgramCall.Offset too so hole subprograms follow the part.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:17:26 -04:00
aj 0e299d7f6f feat(cincinnati): seed material library defaults and add selector dropdown
Adds the full Cincinnati material/etch library list as the committed
default config (seeded into Posts/ on build only when no runtime config
exists), plus a Selected Library override in the PropertyGrid backed by
a TypeConverter that populates from MaterialLibraries. MainForm calls
the new IPostProcessorNestAware hook before showing the config so the
dropdown opens preselected to the best match by nest material and
nearest thickness.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:16:29 -04:00
aj c6f544c5d7 feat(ui): populate material combobox from post processors
Replaces the material textbox on EditNestInfoForm with a combobox whose
items are aggregated from every loaded post processor that implements the
new IMaterialProvidingPostProcessor interface. CincinnatiPostProcessor
exposes its configured MaterialLibraries entries. Free-text entry still
works so custom materials remain usable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:12:54 -04:00
aj 9563094c2b fix(ui): show Drawings tab before Plates in EditNestForm
Users need to import a drawing first, so Drawings tab should be the
default landing tab to reduce steps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:10:58 -04:00
89 changed files with 2623 additions and 482 deletions
+4 -28
View File
@@ -41,7 +41,6 @@ static class NestConsole
}
}
using var log = SetUpLog(options);
var nest = LoadOrCreateNest(options);
if (nest == null)
@@ -68,10 +67,6 @@ static class NestConsole
var overlapCount = CheckOverlaps(plate, options);
// Flush and close the log before printing results.
Trace.Flush();
log?.Dispose();
PrintResults(success, plate, elapsed);
Save(nest, options);
PostProcess(nest, options);
@@ -112,9 +107,6 @@ static class NestConsole
case "--no-save":
o.NoSave = true;
break;
case "--no-log":
o.NoLog = true;
break;
case "--keep-parts":
o.KeepParts = true;
break;
@@ -153,28 +145,14 @@ static class NestConsole
return o;
}
static StreamWriter SetUpLog(Options options)
{
if (options.NoLog)
return null;
var baseDir = Path.GetDirectoryName(options.InputFiles[0]);
var logDir = Path.Combine(baseDir, "test-harness-logs");
Directory.CreateDirectory(logDir);
var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log");
var writer = new StreamWriter(logFile) { AutoFlush = true };
Trace.Listeners.Add(new TextWriterTraceListener(writer));
Console.WriteLine($"Debug log: {logFile}");
return writer;
}
static Nest LoadOrCreateNest(Options options)
{
var nestFile = options.InputFiles.FirstOrDefault(f =>
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
var dxfFiles = options.InputFiles.Where(f =>
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToList();
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToList();
// If we have a nest file, load it and optionally add DXFs.
if (nestFile != null)
@@ -210,7 +188,7 @@ static class NestConsole
// DXF-only mode: create a fresh nest.
if (dxfFiles.Count == 0)
{
Console.Error.WriteLine("Error: no nest (.nest) or DXF (.dxf) files specified");
Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg) files specified");
return null;
}
@@ -484,7 +462,7 @@ static class NestConsole
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
Console.Error.WriteLine();
Console.Error.WriteLine("Arguments:");
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf drawing files");
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf/.dwg drawing files");
Console.Error.WriteLine();
Console.Error.WriteLine("Modes:");
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
@@ -503,7 +481,6 @@ static class NestConsole
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
Console.Error.WriteLine(" --no-save Skip saving output file");
Console.Error.WriteLine(" --no-log Skip writing debug log file");
Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)");
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
@@ -522,7 +499,6 @@ static class NestConsole
public Size? PlateSize;
public bool CheckOverlaps;
public bool NoSave;
public bool NoLog;
public bool KeepParts;
public bool AutoNest;
public string TemplateFile;
@@ -305,9 +305,6 @@ namespace OpenNest.CNC.CuttingStrategy
subPgm.Codes.AddRange(leadIn.Generate(relativePoint, normal, winding));
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
reindexed = TrimShapeForTab(reindexed, relativePoint, Parameters.TabConfig.Size);
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding));
subPgm.Mode = Mode.Incremental;
@@ -331,7 +328,7 @@ namespace OpenNest.CNC.CuttingStrategy
var reindexedShape = shape.ReindexAt(point, entity);
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
if (Parameters.TabsEnabled && Parameters.TabConfig != null && contourType == ContourType.External)
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
+12
View File
@@ -128,6 +128,12 @@ namespace OpenNest.CNC
{
var code = Codes[i];
if (code is SubProgramCall subpgm)
{
subpgm.Offset = new Geometry.Vector(
subpgm.Offset.X + x, subpgm.Offset.Y + y);
}
if (code is Motion == false)
continue;
@@ -150,6 +156,12 @@ namespace OpenNest.CNC
{
var code = Codes[i];
if (code is SubProgramCall subpgm)
{
subpgm.Offset = new Geometry.Vector(
subpgm.Offset.X + voffset.X, subpgm.Offset.Y + voffset.Y);
}
if (code is Motion == false)
continue;
+80
View File
@@ -0,0 +1,80 @@
using OpenNest.Geometry;
using System.Collections.Generic;
namespace OpenNest.CNC
{
public static class RapidEnumerator
{
public readonly record struct Segment(Vector From, Vector To);
public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
{
var results = new List<Segment>();
// Draw the rapid from the previous tool position to the program's first
// pierce point. This also primes pos so the interior walk interprets
// Incremental deltas from the correct absolute location (basePos), which
// matters for raw pre-lead-in programs that are emitted Incremental.
var firstPierce = FirstPiercePoint(pgm, basePos);
results.Add(new Segment(startPos, firstPierce));
var pos = firstPierce;
Walk(pgm, basePos, ref pos, skipFirst: true, results);
return results;
}
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
{
for (var i = 0; i < pgm.Length; i++)
{
if (pgm[i] is SubProgramCall call && call.Program != null)
return FirstPiercePoint(call.Program, basePos + call.Offset);
if (pgm[i] is Motion motion)
return motion.EndPoint + basePos;
}
return basePos;
}
private static void Walk(Program pgm, Vector basePos, ref Vector pos, bool skipFirst, List<Segment> results)
{
var skipped = !skipFirst;
for (var i = 0; i < pgm.Length; ++i)
{
var code = pgm[i];
if (code is SubProgramCall { Program: { } program } call)
{
var holeBase = basePos + call.Offset;
var firstPierce = FirstPiercePoint(program, holeBase);
if (!skipped)
skipped = true;
else
results.Add(new Segment(pos, firstPierce));
var subPos = holeBase;
Walk(program, holeBase, ref subPos, skipFirst: true, results);
pos = subPos;
}
else if (code is Motion motion)
{
var endpt = pgm.Mode == Mode.Incremental
? motion.EndPoint + pos
: motion.EndPoint + basePos;
if (code.Type == CodeType.RapidMove)
{
if (!skipped)
skipped = true;
else
results.Add(new Segment(pos, endpt));
}
pos = endpt;
}
}
}
}
}
+78
View File
@@ -0,0 +1,78 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using System.Linq;
namespace OpenNest
{
/// <summary>
/// Computes the rotation that maps a drawing to its canonical (MBR-axis-aligned) frame.
/// Lives in OpenNest.Core so Drawing.Program setter can invoke it directly without
/// a circular dependency on OpenNest.Engine.
/// </summary>
public static class CanonicalAngle
{
/// <summary>Angles with |v| below this (radians) are snapped to 0.</summary>
public const double SnapToZero = 0.001;
/// <summary>
/// Derives the canonical angle from a pre-computed MBR. Used both by Compute (which
/// computes the MBR itself) and by PartClassifier (which already has one). Single formula
/// across both callers.
/// </summary>
public static double FromMbr(BoundingRectangleResult mbr)
{
if (mbr.Area <= OpenNest.Math.Tolerance.Epsilon)
return 0.0;
// The MBR edge angle can represent any of four equivalent orientations
// (edge-i, edge-i + π/2, edge-i + π, edge-i - π/2) depending on which hull
// edge the algorithm happened to pick. Normalize -mbr.Angle to the
// representative in [-π/4, π/4] so snap-to-zero works for inputs near
// ANY of the equivalent orientations.
var angle = -mbr.Angle;
const double halfPi = System.Math.PI / 2.0;
angle -= halfPi * System.Math.Round(angle / halfPi);
if (System.Math.Abs(angle) < SnapToZero)
return 0.0;
return angle;
}
public static double Compute(Drawing drawing)
{
if (drawing?.Program == null)
return 0.0;
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0)
return 0.0;
var perimeter = shapes[0];
var perimeterArea = perimeter.Area();
for (var i = 1; i < shapes.Count; i++)
{
var area = shapes[i].Area();
if (area > perimeterArea)
{
perimeter = shapes[i];
perimeterArea = area;
}
}
var polygon = perimeter.ToPolygonWithTolerance(0.1);
if (polygon == null || polygon.Vertices.Count < 3)
return 0.0;
var hull = ConvexHull.Compute(polygon.Vertices);
if (hull.Vertices.Count < 3)
return 0.0;
var mbr = RotatingCalipers.MinimumBoundingRectangle(hull);
return FromMbr(mbr);
}
}
}
+14 -4
View File
@@ -1,5 +1,6 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Converters
@@ -81,12 +82,21 @@ namespace OpenNest.Converters
var startpt = arc.StartPoint();
var endpt = arc.EndPoint();
if (startpt != lastpt)
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt);
lastpt = endpt;
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
var sweep = System.Math.Abs(arc.SweepAngle());
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
{
pgm.LineTo(endpt);
}
else
{
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
}
return lastpt;
}
@@ -94,7 +104,7 @@ namespace OpenNest.Converters
{
var startpt = new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
if (startpt != lastpt)
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt);
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
@@ -105,7 +115,7 @@ namespace OpenNest.Converters
private static Vector AddLine(Program pgm, Vector lastpt, Line line)
{
if (line.StartPoint != lastpt)
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(line.StartPoint);
var move = new LinearMove(line.EndPoint);
+32 -2
View File
@@ -16,7 +16,7 @@ namespace OpenNest
private static int nextColorIndex;
private Program program;
public static readonly Color[] PartColors = new Color[]
public static Color[] PartColors = new Color[]
{
Color.FromArgb(205, 92, 92), // Indian Red
Color.FromArgb(148, 103, 189), // Medium Purple
@@ -54,9 +54,9 @@ namespace OpenNest
Id = Interlocked.Increment(ref nextId);
Name = name;
Material = new Material();
Program = pgm;
Constraints = new NestConstraints();
Source = new SourceInfo();
Program = pgm;
}
public int Id { get; }
@@ -78,9 +78,29 @@ namespace OpenNest
{
program = value;
UpdateArea();
RecomputeCanonicalAngle();
}
}
/// <summary>
/// Recomputes and stores the canonical angle from the current Program.
/// Callers that mutate Program in place (rather than reassigning it) must invoke this explicitly.
/// Cut-off drawings are left with Angle=0.
/// </summary>
public void RecomputeCanonicalAngle()
{
if (Source == null)
Source = new SourceInfo();
if (program == null || IsCutOff)
{
Source.Angle = 0.0;
return;
}
Source.Angle = CanonicalAngle.Compute(this);
}
public Color Color { get; set; }
public bool IsCutOff { get; set; }
@@ -163,5 +183,15 @@ namespace OpenNest
/// Offset distances to the original location.
/// </summary>
public Vector Offset { get; set; }
/// <summary>
/// Rotation (radians) that maps the source program geometry to its canonical
/// (MBR-axis-aligned) frame. Populated automatically by the <see cref="Drawing.Program"/>
/// setter via <see cref="CanonicalAngle.Compute"/>. A value of 0 means the drawing is
/// already canonical or <see cref="Drawing.IsCutOff"/> is true. Callers that mutate
/// <see cref="Drawing.Program"/> in place must invoke
/// <see cref="Drawing.RecomputeCanonicalAngle"/> to refresh.
/// </summary>
public double Angle { get; set; }
}
}
+17 -14
View File
@@ -404,26 +404,29 @@ namespace OpenNest.Geometry
maxY = startpt.Y;
}
var angle1 = StartAngle;
var angle2 = EndAngle;
var sweep = SweepAngle();
if (sweep > Tolerance.Epsilon)
{
var angle1 = StartAngle;
var angle2 = EndAngle;
// switch the angle to counter clockwise.
if (IsReversed)
Generic.Swap(ref angle1, ref angle2);
if (IsReversed)
Generic.Swap(ref angle1, ref angle2);
if (Angle.IsBetweenRad(Angle.HalfPI, angle1, angle2))
maxY = Center.Y + Radius;
if (Angle.IsBetweenRad(Angle.HalfPI, angle1, angle2))
maxY = Center.Y + Radius;
if (Angle.IsBetweenRad(System.Math.PI, angle1, angle2))
minX = Center.X - Radius;
if (Angle.IsBetweenRad(System.Math.PI, angle1, angle2))
minX = Center.X - Radius;
const double oneHalfPI = System.Math.PI * 1.5;
const double oneHalfPI = System.Math.PI * 1.5;
if (Angle.IsBetweenRad(oneHalfPI, angle1, angle2))
minY = Center.Y - Radius;
if (Angle.IsBetweenRad(oneHalfPI, angle1, angle2))
minY = Center.Y - Radius;
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
maxX = Center.X + Radius;
if (Angle.IsBetweenRad(Angle.TwoPI, angle1, angle2))
maxX = Center.X + Radius;
}
boundingBox.X = minX;
boundingBox.Y = minY;
+17 -2
View File
@@ -1,8 +1,9 @@
using OpenNest.Math;
using System;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public class Box
public class Box : IComparable<Box>
{
public static readonly Box Empty = new Box();
@@ -214,5 +215,19 @@ namespace OpenNest.Geometry
{
return string.Format("[Box: X={0}, Y={1}, Width={2}, Length={3}]", X, Y, Width, Length);
}
public int CompareTo(Box other)
{
var cmp = Width.CompareTo(other.Width);
return cmp != 0 ? cmp : Length.CompareTo(other.Length);
}
public static bool operator >(Box a, Box b) => a.CompareTo(b) > 0;
public static bool operator <(Box a, Box b) => a.CompareTo(b) < 0;
public static bool operator >=(Box a, Box b) => a.CompareTo(b) >= 0;
public static bool operator <=(Box a, Box b) => a.CompareTo(b) <= 0;
}
}
+5 -1
View File
@@ -173,7 +173,11 @@ namespace OpenNest.Geometry
if (maxDev <= tolerance)
{
results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1));
var arc = CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1);
if (arc.SweepAngle() < Tolerance.Epsilon)
results.Add(new Line(p0, p1));
else
results.Add(arc);
}
else
{
+92 -1
View File
@@ -1,12 +1,13 @@
using OpenNest.Math;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace OpenNest.Geometry
{
public static class ShapeBuilder
{
public static List<Shape> GetShapes(IEnumerable<Entity> entities)
public static List<Shape> GetShapes(IEnumerable<Entity> entities, double? weldTolerance = null)
{
var lines = new List<Line>();
var arcs = new List<Arc>();
@@ -57,6 +58,9 @@ namespace OpenNest.Geometry
entityList.AddRange(lines);
entityList.AddRange(arcs);
if (weldTolerance.HasValue)
WeldEndpoints(entityList, weldTolerance.Value);
while (entityList.Count > 0)
{
var next = entityList[0];
@@ -107,6 +111,93 @@ namespace OpenNest.Geometry
return shapes;
}
public static void WeldEndpoints(List<Entity> entities, double tolerance)
{
var endpointGroups = new List<List<(Entity entity, bool isStart, Vector point)>>();
foreach (var entity in entities)
{
var (start, end) = GetEndpoints(entity);
if (!start.IsValid() || !end.IsValid())
continue;
AddToGroup(endpointGroups, entity, true, start, tolerance);
AddToGroup(endpointGroups, entity, false, end, tolerance);
}
foreach (var group in endpointGroups)
{
if (group.Count <= 1)
continue;
var avgX = group.Average(g => g.point.X);
var avgY = group.Average(g => g.point.Y);
var weldedPoint = new Vector(avgX, avgY);
foreach (var (entity, isStart, _) in group)
ApplyWeld(entity, isStart, weldedPoint);
}
}
private static void AddToGroup(
List<List<(Entity entity, bool isStart, Vector point)>> groups,
Entity entity, bool isStart, Vector point, double tolerance)
{
foreach (var group in groups)
{
if (group[0].point.DistanceTo(point) <= tolerance)
{
group.Add((entity, isStart, point));
return;
}
}
groups.Add(new List<(Entity, bool, Vector)> { (entity, isStart, point) });
}
private static (Vector start, Vector end) GetEndpoints(Entity entity)
{
switch (entity.Type)
{
case EntityType.Arc:
var arc = (Arc)entity;
return (arc.StartPoint(), arc.EndPoint());
case EntityType.Line:
var line = (Line)entity;
return (line.StartPoint, line.EndPoint);
default:
return (Vector.Invalid, Vector.Invalid);
}
}
private static void ApplyWeld(Entity entity, bool isStart, Vector weldedPoint)
{
switch (entity.Type)
{
case EntityType.Line:
var line = (Line)entity;
if (isStart)
line.StartPoint = weldedPoint;
else
line.EndPoint = weldedPoint;
break;
case EntityType.Arc:
var arc = (Arc)entity;
var deltaX = weldedPoint.X - arc.Center.X;
var deltaY = weldedPoint.Y - arc.Center.Y;
var angle = System.Math.Atan2(deltaY, deltaX);
if (isStart)
arc.StartAngle = angle;
else
arc.EndAngle = angle;
break;
}
}
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
{
var tol = Tolerance.ChainTolerance;
@@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace OpenNest
{
public interface IMaterialProvidingPostProcessor
{
IEnumerable<string> GetMaterialNames();
}
}
+7
View File
@@ -0,0 +1,7 @@
namespace OpenNest
{
public interface IPostProcessorNestAware
{
void PrepareForNest(Nest nest);
}
}
@@ -3,7 +3,7 @@ using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace OpenNest.IO.Bom
namespace OpenNest.Math
{
public static class Fraction
{
+2
View File
@@ -7,6 +7,8 @@ namespace OpenNest.Shapes
{
public double Diameter { get; set; }
public override string GenerateName() => $"Circle {Dim(Diameter)} Dia";
public override void SetPreviewDefaults()
{
Diameter = 8;
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
public double Base { get; set; }
public double Height { get; set; }
public override string GenerateName() => $"Isosceles Triangle {Dim(Base)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
Base = 8;
+2
View File
@@ -10,6 +10,8 @@ namespace OpenNest.Shapes
public double LegWidth { get; set; }
public double LegHeight { get; set; }
public override string GenerateName() => $"L {Dim(Width)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
Width = 8;
@@ -3,33 +3,40 @@ using System.Collections.Generic;
namespace OpenNest.Shapes
{
public class OctagonShape : ShapeDefinition
public class NgonShape : ShapeDefinition
{
public int Sides { get; set; }
public double Width { get; set; }
public override string GenerateName() => $"{Sides}-Sided Polygon {Dim(Width)}";
public override void SetPreviewDefaults()
{
Sides = 8;
Width = 8;
}
public override Drawing GetDrawing()
{
var n = Sides < 3 ? 3 : Sides;
var center = Width / 2.0;
var circumRadius = Width / (2.0 * System.Math.Cos(System.Math.PI / 8.0));
var circumRadius = Width / (2.0 * System.Math.Cos(System.Math.PI / n));
var step = 2.0 * System.Math.PI / n;
var start = System.Math.PI / n;
var vertices = new Vector[8];
for (var i = 0; i < 8; i++)
var vertices = new Vector[n];
for (var i = 0; i < n; i++)
{
var angle = System.Math.PI / 8.0 + i * System.Math.PI / 4.0;
var angle = start + i * step;
vertices[i] = new Vector(
center + circumRadius * System.Math.Cos(angle),
center + circumRadius * System.Math.Sin(angle));
}
var entities = new List<Entity>();
for (var i = 0; i < 8; i++)
for (var i = 0; i < n; i++)
{
var next = (i + 1) % 8;
var next = (i + 1) % n;
entities.Add(new Line(vertices[i], vertices[next]));
}
+8
View File
@@ -13,6 +13,14 @@ namespace OpenNest.Shapes
public double PipeClearance { get; set; }
public bool Blind { get; set; }
public override string GenerateName()
{
var name = $"Pipe Flange {Dim(OD)} OD";
if (!string.IsNullOrEmpty(PipeSize))
name += $" {PipeSize} Pipe";
return name;
}
public override void SetPreviewDefaults()
{
OD = 7.5;
+2
View File
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
public double Length { get; set; }
public double Width { get; set; }
public override string GenerateName() => $"Rectangle {Dim(Length)}x{Dim(Width)}";
public override void SetPreviewDefaults()
{
Length = 12;
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
public double Width { get; set; }
public double Height { get; set; }
public override string GenerateName() => $"Right Triangle {Dim(Width)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
Width = 8;
+2
View File
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
public double OuterDiameter { get; set; }
public double InnerDiameter { get; set; }
public override string GenerateName() => $"Ring {Dim(OuterDiameter)}x{Dim(InnerDiameter)}";
public override void SetPreviewDefaults()
{
OuterDiameter = 10;
@@ -10,6 +10,8 @@ namespace OpenNest.Shapes
public double Width { get; set; }
public double Radius { get; set; }
public override string GenerateName() => $"Rounded Rectangle {Dim(Length)}x{Dim(Width)} R{Dim(Radius)}";
public override void SetPreviewDefaults()
{
Length = 12;
+10
View File
@@ -26,6 +26,14 @@ namespace OpenNest.Shapes
public abstract Drawing GetDrawing();
public virtual string GenerateName()
{
var typeName = GetType().Name;
return typeName.EndsWith("Shape")
? typeName.Substring(0, typeName.Length - 5)
: typeName;
}
public virtual void SetPreviewDefaults() { }
public static List<T> LoadFromJson<T>(string path) where T : ShapeDefinition
@@ -34,6 +42,8 @@ namespace OpenNest.Shapes
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions);
}
protected static string Dim(double value) => value.ToString("0.###");
protected Drawing CreateDrawing(List<Entity> entities)
{
var pgm = ConvertGeometry.ToProgram(entities);
+2
View File
@@ -10,6 +10,8 @@ namespace OpenNest.Shapes
public double StemWidth { get; set; }
public double BarHeight { get; set; }
public override string GenerateName() => $"T {Dim(Width)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
Width = 10;
+2
View File
@@ -9,6 +9,8 @@ namespace OpenNest.Shapes
public double BottomWidth { get; set; }
public double Height { get; set; }
public override string GenerateName() => $"Trapezoid {Dim(TopWidth)}x{Dim(BottomWidth)}x{Dim(Height)}";
public override void SetPreviewDefaults()
{
TopWidth = 6;
+10 -4
View File
@@ -24,6 +24,9 @@ namespace OpenNest.Engine.BestFit
if (_cache.TryGetValue(key, out var cached))
return cached;
// Operate on the canonical frame so cached pair positions are orientation-invariant.
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
IPairEvaluator evaluator = null;
ISlideComputer slideComputer = null;
@@ -31,7 +34,7 @@ namespace OpenNest.Engine.BestFit
{
if (CreateEvaluator != null)
{
try { evaluator = CreateEvaluator(drawing, spacing); }
try { evaluator = CreateEvaluator(canonical, spacing); }
catch { /* fall back to default evaluator */ }
}
@@ -42,7 +45,7 @@ namespace OpenNest.Engine.BestFit
}
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
var results = finder.FindBestFits(drawing, spacing, StepSize);
var results = finder.FindBestFits(canonical, spacing, StepSize);
_cache.TryAdd(key, results);
return results;
@@ -86,9 +89,12 @@ namespace OpenNest.Engine.BestFit
try
{
// Operate on the canonical frame so cached pair positions are orientation-invariant.
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
if (CreateEvaluator != null)
{
try { evaluator = CreateEvaluator(drawing, spacing); }
try { evaluator = CreateEvaluator(canonical, spacing); }
catch { /* fall back to default evaluator */ }
}
@@ -100,7 +106,7 @@ namespace OpenNest.Engine.BestFit
// Compute candidates and evaluate once with the largest plate.
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
var baseResults = finder.FindBestFits(drawing, spacing, StepSize);
var baseResults = finder.FindBestFits(canonical, spacing, StepSize);
// Cache a filtered copy for each plate size.
foreach (var size in needed)
@@ -1,18 +1,10 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.IO;
namespace OpenNest.Engine.BestFit
{
public class NfpSlideStrategy : IBestFitStrategy
{
private static readonly string LogPath = Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop),
"nfp-slide-debug.log");
private static readonly object LogLock = new object();
private readonly double _part2Rotation;
private readonly Polygon _stationaryPerimeter;
private readonly Polygon _stationaryHull;
@@ -46,12 +38,6 @@ namespace OpenNest.Engine.BestFit
var hull = ConvexHull.Compute(result.Polygon.Vertices);
Log($"=== Create: drawing={drawing.Name}, rotation={Angle.ToDegrees(part2Rotation):F1}deg ===");
Log($" Perimeter: {result.Polygon.Vertices.Count} verts, bounds={FormatBounds(result.Polygon)}");
Log($" Hull: {hull.Vertices.Count} verts, bounds={FormatBounds(hull)}");
Log($" Correction: ({result.Correction.X:F4}, {result.Correction.Y:F4})");
Log($" ProgramBBox: {drawing.Program.BoundingBox()}");
return new NfpSlideStrategy(part2Rotation, type, description,
result.Polygon, hull, result.Correction);
}
@@ -63,40 +49,17 @@ namespace OpenNest.Engine.BestFit
if (stepSize <= 0)
return candidates;
Log($"--- GenerateCandidates: drawing={drawing.Name}, part2Rot={Angle.ToDegrees(_part2Rotation):F1}deg, spacing={spacing}, stepSize={stepSize} ---");
// Orbiting polygon: same shape rotated to Part2's angle.
var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
Log($" Stationary hull: {_stationaryHull.Vertices.Count} verts, bounds={FormatBounds(_stationaryHull)}");
Log($" Orbiting perimeter (rotated): {orbitingPerimeter.Vertices.Count} verts, bounds={FormatBounds(orbitingPerimeter)}");
Log($" Orbiting hull: {orbitingPoly.Vertices.Count} verts, bounds={FormatBounds(orbitingPoly)}");
var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly);
if (nfp == null || nfp.Vertices.Count < 3)
{
Log($" NFP failed or degenerate (verts={nfp?.Vertices.Count ?? 0})");
return candidates;
}
var verts = nfp.Vertices;
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
Log($" NFP: {verts.Count} verts (closed={nfp.IsClosed()}, walking {vertCount}), bounds={FormatBounds(nfp)}");
Log($" Correction: ({_correction.X:F4}, {_correction.Y:F4})");
// Log NFP vertices
for (var v = 0; v < vertCount; v++)
Log($" NFP vert[{v}]: ({verts[v].X:F4}, {verts[v].Y:F4}) -> corrected: ({verts[v].X - _correction.X:F4}, {verts[v].Y - _correction.Y:F4})");
// Compare with what RotationSlideStrategy would produce
var part1 = Part.CreateAtOrigin(drawing);
var part2 = Part.CreateAtOrigin(drawing, _part2Rotation);
Log($" Part1 (rot=0): loc=({part1.Location.X:F4}, {part1.Location.Y:F4}), bbox={part1.BoundingBox}");
Log($" Part2 (rot={Angle.ToDegrees(_part2Rotation):F1}): loc=({part2.Location.X:F4}, {part2.Location.Y:F4}), bbox={part2.BoundingBox}");
var testNumber = 0;
for (var i = 0; i < vertCount; i++)
@@ -125,20 +88,6 @@ namespace OpenNest.Engine.BestFit
}
}
// Log overlap check for vertex candidates (first few)
var checkCount = System.Math.Min(vertCount, 8);
for (var c = 0; c < checkCount; c++)
{
var cand = candidates[c];
var p2 = Part.CreateAtOrigin(drawing, cand.Part2Rotation);
p2.Location = cand.Part2Offset;
var overlaps = part1.Intersects(p2, out _);
Log($" Candidate[{c}]: offset=({cand.Part2Offset.X:F4}, {cand.Part2Offset.Y:F4}), overlaps={overlaps}");
}
Log($" Total candidates: {candidates.Count}");
Log("");
return candidates;
}
@@ -160,20 +109,5 @@ namespace OpenNest.Engine.BestFit
Spacing = spacing
};
}
private static string FormatBounds(Polygon polygon)
{
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
return $"[({bb.Left:F4}, {bb.Bottom:F4})-({bb.Right:F4}, {bb.Top:F4}), {bb.Width:F2}x{bb.Length:F2}]";
}
private static void Log(string message)
{
lock (LogLock)
{
File.AppendAllText(LogPath, message + "\n");
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.Engine
{
/// <summary>
/// Produces transient canonical (MBR-axis-aligned) copies of drawings for engine consumption
/// and un-rotates placed parts back to the drawing's original frame.
/// </summary>
public static class CanonicalFrame
{
/// <summary>
/// Returns a new Drawing whose Program geometry is rotated to the canonical frame.
/// The source drawing is not mutated.
/// </summary>
public static Drawing AsCanonicalCopy(Drawing drawing)
{
if (drawing == null)
return null;
var angle = drawing.Source?.Angle ?? 0.0;
// Clone program (never mutate the source).
var pgm = (drawing.Program.Clone() as OpenNest.CNC.Program)
?? new OpenNest.CNC.Program();
if (!Tolerance.IsEqualTo(angle, 0))
pgm.Rotate(angle, pgm.BoundingBox().Center);
var copy = new Drawing(drawing.Name ?? string.Empty, pgm)
{
Color = drawing.Color,
Constraints = drawing.Constraints,
Material = drawing.Material,
Priority = drawing.Priority,
Customer = drawing.Customer,
IsCutOff = drawing.IsCutOff,
Source = new SourceInfo
{
Path = drawing.Source?.Path,
Offset = drawing.Source?.Offset ?? new Vector(0, 0),
Angle = 0.0,
},
};
return copy;
}
/// <summary>
/// Composes the source drawing's canonical angle onto each placed part so the
/// returned list is in the drawing's original (visible) frame.
///
/// Derivation: let sourceAngle = S (rotation mapping source -> canonical).
/// Canonical part at rotation R shows visible orientation R.
/// Source part at rotation R' shows visible orientation R' + (-S), because the
/// source geometry is already rotated by -S relative to canonical.
/// Setting equal gives R' = R + S, so we ADD sourceAngle to each placed part.
///
/// Rotation is performed around the part's Location so its placement position is preserved;
/// only the orientation composes.
/// </summary>
public static List<Part> FromCanonical(List<Part> placed, double sourceAngle)
{
if (placed == null || placed.Count == 0)
return placed;
if (Tolerance.IsEqualTo(sourceAngle, 0))
return placed;
foreach (var p in placed)
p.Rotate(sourceAngle, p.Location);
return placed;
}
}
}
+63 -19
View File
@@ -47,14 +47,29 @@ namespace OpenNest
PhaseResults.Clear();
AngleResults.Clear();
// Fast path: for very small quantities, skip the full strategy pipeline.
if (item.Quantity > 0 && item.Quantity <= 2)
// Replace the item's Drawing with a canonical copy for the duration of this fill.
// All internal methods see canonical geometry; this wrapper un-canonicalizes the final result.
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
var originalDrawing = item.Drawing;
var canonicalItem = new NestItem
{
var fast = TryFillSmallQuantity(item, workArea);
if (fast != null && fast.Count >= item.Quantity)
Drawing = CanonicalFrame.AsCanonicalCopy(item.Drawing),
Quantity = item.Quantity,
Priority = item.Priority,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
StepAngle = item.StepAngle,
};
// Fast path for qty 1-2.
if (canonicalItem.Quantity > 0 && canonicalItem.Quantity <= 2)
{
var fast = TryFillSmallQuantity(canonicalItem, workArea);
if (fast != null && fast.Count >= canonicalItem.Quantity)
{
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={item.Quantity}");
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={canonicalItem.Quantity}");
WinnerPhase = NestPhase.Pairs;
fast = RebindAndUnCanonicalize(fast, originalDrawing, sourceAngle);
ReportProgress(progress, new ProgressReport
{
Phase = WinnerPhase,
@@ -68,32 +83,30 @@ namespace OpenNest
}
}
// For low quantities, shrink the work area in both dimensions to avoid
// running expensive strategies against the full plate.
var effectiveWorkArea = workArea;
if (item.Quantity > 0)
if (canonicalItem.Quantity > 0)
{
effectiveWorkArea = ShrinkWorkArea(item, workArea, Plate.PartSpacing);
effectiveWorkArea = ShrinkWorkArea(canonicalItem, workArea, Plate.PartSpacing);
if (effectiveWorkArea != workArea)
Debug.WriteLine($"[Fill] Low-qty shrink: {item.Quantity} requested, " +
Debug.WriteLine($"[Fill] Low-qty shrink: {canonicalItem.Quantity} requested, " +
$"from {workArea.Width:F1}x{workArea.Length:F1} " +
$"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}");
}
var best = RunFillPipeline(item, effectiveWorkArea, progress, token);
var best = RunFillPipeline(canonicalItem, effectiveWorkArea, progress, token);
// Fallback: if the reduced area didn't yield enough, retry with full area.
if (item.Quantity > 0 && best.Count < item.Quantity && effectiveWorkArea != workArea)
if (canonicalItem.Quantity > 0 && best.Count < canonicalItem.Quantity && effectiveWorkArea != workArea)
{
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {item.Quantity}, retrying full area");
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {canonicalItem.Quantity}, retrying full area");
PhaseResults.Clear();
AngleResults.Clear();
best = RunFillPipeline(item, workArea, progress, token);
best = RunFillPipeline(canonicalItem, workArea, progress, token);
}
if (item.Quantity > 0 && best.Count > item.Quantity)
best = ShrinkFiller.TrimToCount(best, item.Quantity, TrimAxis);
if (canonicalItem.Quantity > 0 && best.Count > canonicalItem.Quantity)
best = ShrinkFiller.TrimToCount(best, canonicalItem.Quantity, TrimAxis);
best = RebindAndUnCanonicalize(best, originalDrawing, sourceAngle);
ReportProgress(progress, new ProgressReport
{
@@ -108,6 +121,31 @@ namespace OpenNest
return best;
}
/// <summary>
/// Single exit point for canonical -> source frame conversion. Rebinds every Part to the
/// original Drawing (so consumers see the user's drawing identity, not the transient canonical copy)
/// and composes sourceAngle onto each Part's rotation via CanonicalFrame.FromCanonical.
/// </summary>
private static List<Part> RebindAndUnCanonicalize(List<Part> parts, Drawing original, double sourceAngle)
{
if (parts == null || parts.Count == 0)
return parts;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
// Rebind to `original` while preserving world pose. CreateAtOrigin rotates
// at the origin (keeping bbox at world (0,0)) then we offset to match p's bbox.
var rebound = Part.CreateAtOrigin(original, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return CanonicalFrame.FromCanonical(parts, sourceAngle);
}
/// <summary>
/// Fast path for qty 1-2: place a single part or a best-fit pair
/// without running the full strategy pipeline.
@@ -139,6 +177,10 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute(
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
// Build pair candidates with a canonical drawing so their geometry matches
// the coordinate frame of the cached fit results.
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(drawing);
List<Part> bestPlacement = null;
foreach (var fit in bestFits)
@@ -152,7 +194,7 @@ namespace OpenNest
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
continue;
var landscape = fit.BuildParts(drawing);
var landscape = fit.BuildParts(canonicalDrawing);
var portrait = RotatePair90(landscape);
var lFits = TryOffsetToWorkArea(landscape, workArea);
@@ -174,6 +216,8 @@ namespace OpenNest
bestPlacement = candidate;
}
// Parts are returned in canonical frame, bound to the canonical drawing.
// The outer Fill wrapper (Task 7) rebinds to `drawing` and composes sourceAngle onto rotation.
return bestPlacement;
}
+55 -130
View File
@@ -61,92 +61,91 @@ namespace OpenNest.Engine.Fill
: NestDirection.Horizontal;
}
/// <summary>
/// Computes the slide distance for the push algorithm, returning the
/// geometry-aware copy distance along the given axis.
/// </summary>
private double ComputeCopyDistance(double bboxDim, double slideDistance)
{
if (slideDistance >= double.MaxValue || slideDistance < 0)
return bboxDim + PartSpacing;
// The geometry-aware slide can produce a copy distance smaller than
// the part itself when inflated corner/arc vertices interact spuriously.
// Clamp to bboxDim + PartSpacing to prevent bounding box overlap.
return System.Math.Max(bboxDim - slideDistance, bboxDim + PartSpacing);
}
/// <summary>
/// Finds the geometry-aware copy distance between two identical parts along an axis.
/// Both parts are inflated by half-spacing for symmetric spacing.
/// Uses native Line/Arc entities (inflated by half-spacing) so curves are handled
/// exactly without polygon sampling error.
/// </summary>
private double FindCopyDistance(Part partA, NestDirection direction, PartBoundary boundary)
private double FindCopyDistance(Part partA, NestDirection direction)
{
var bboxDim = GetDimension(partA.BoundingBox, direction);
var pushDir = GetPushDirection(direction);
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
var offset = MakeOffset(direction, startOffset);
var locationBOffset = MakeOffset(direction, bboxDim);
var stationaryEntities = PartGeometry.GetOffsetPerimeterEntities(partA, HalfSpacing);
var movingEntities = PartGeometry.GetOffsetPerimeterEntities(
partA.CloneAtOffset(offset), HalfSpacing);
// Use the most efficient array-based overload to avoid all allocations.
var slideDistance = SpatialQuery.DirectionalDistance(
boundary.GetEdges(pushDir), partA.Location + locationBOffset,
boundary.GetEdges(SpatialQuery.OppositeDirection(pushDir)), partA.Location,
pushDir);
movingEntities, stationaryEntities, pushDir);
return ComputeCopyDistance(bboxDim, slideDistance);
if (slideDistance >= double.MaxValue || slideDistance < 0)
return bboxDim + PartSpacing;
return startOffset - slideDistance;
}
/// <summary>
/// Finds the geometry-aware copy distance between two identical patterns along an axis.
/// Checks every pair of parts across adjacent patterns so that multi-part
/// patterns (e.g. interlocking pairs) maintain spacing between ALL parts.
/// Both sides are inflated by half-spacing for symmetric spacing.
/// Checks every pair of parts across adjacent pattern copies so multi-part patterns
/// (e.g. interlocking pairs) maintain spacing between ALL parts. Uses native entity
/// geometry inflated by half-spacing — same primitive the Compactor uses — so arcs
/// are exact and no bbox clamp is needed.
/// </summary>
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary[] boundaries)
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction)
{
if (patternA.Parts.Count <= 1)
return FindSinglePartPatternCopyDistance(patternA, direction, boundaries[0]);
if (patternA.Parts.Count == 1)
return FindCopyDistance(patternA.Parts[0], direction);
var bboxDim = GetDimension(patternA.BoundingBox, direction);
var pushDir = GetPushDirection(direction);
var opposite = SpatialQuery.OppositeDirection(pushDir);
var dirVec = SpatialQuery.DirectionToOffset(pushDir, 1.0);
// bboxDim already spans max(upper) - min(lower) across all parts,
// so the start offset just needs to push beyond that plus spacing.
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
var offset = MakeOffset(direction, startOffset);
var maxCopyDistance = FindMaxPairDistance(
patternA.Parts, boundaries, offset, pushDir, opposite, startOffset);
var parts = patternA.Parts;
var stationaryBoxes = new Box[parts.Count];
var movingBoxes = new Box[parts.Count];
var stationaryEntities = new List<Entity>[parts.Count];
var movingEntities = new List<Entity>[parts.Count];
// The copy distance must be at least bboxDim + PartSpacing to prevent
// bounding box overlap. Cross-pair slides can underestimate when the
// circumscribed polygon boundary overshoots the true arc, creating
// spurious contacts between diagonal parts in adjacent copies.
return System.Math.Max(maxCopyDistance, bboxDim + PartSpacing);
}
for (var i = 0; i < parts.Count; i++)
{
stationaryBoxes[i] = parts[i].BoundingBox;
movingBoxes[i] = stationaryBoxes[i].Translate(offset);
}
/// <summary>
/// Tests every pair of parts across adjacent pattern copies and returns the
/// maximum copy distance found. Returns 0 if no valid slide was found.
/// </summary>
private static double FindMaxPairDistance(
List<Part> parts, PartBoundary[] boundaries, Vector offset,
PushDirection pushDir, PushDirection opposite, double startOffset)
{
var maxCopyDistance = 0.0;
for (var j = 0; j < parts.Count; j++)
{
var movingEdges = boundaries[j].GetEdges(pushDir);
var locationB = parts[j].Location + offset;
var movingBox = movingBoxes[j];
for (var i = 0; i < parts.Count; i++)
{
var stationaryBox = stationaryBoxes[i];
// Skip if stationary is already ahead of moving in the push direction
// (sliding forward would take them further apart).
if (SpatialQuery.DirectionalGap(movingBox, stationaryBox, opposite) > 0)
continue;
// Skip if bboxes can't overlap along the axis perpendicular to the push.
if (!SpatialQuery.PerpendicularOverlap(movingBox, stationaryBox, dirVec))
continue;
stationaryEntities[i] ??= PartGeometry.GetOffsetPerimeterEntities(
parts[i], HalfSpacing);
movingEntities[j] ??= PartGeometry.GetOffsetPerimeterEntities(
parts[j].CloneAtOffset(offset), HalfSpacing);
var slideDistance = SpatialQuery.DirectionalDistance(
movingEdges, locationB,
boundaries[i].GetEdges(opposite), parts[i].Location,
pushDir);
movingEntities[j], stationaryEntities[i], pushDir);
if (slideDistance >= double.MaxValue || slideDistance < 0)
continue;
@@ -161,86 +160,15 @@ namespace OpenNest.Engine.Fill
return maxCopyDistance;
}
/// <summary>
/// Fast path for single-part patterns — no cross-part conflicts possible.
/// </summary>
private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary boundary)
{
var template = patternA.Parts[0];
return FindCopyDistance(template, direction, boundary);
}
/// <summary>
/// Gets offset boundary lines for all parts in a pattern using a shared boundary.
/// </summary>
private static List<Line> GetPatternLines(Pattern pattern, PartBoundary boundary, PushDirection direction)
{
var lines = new List<Line>();
foreach (var part in pattern.Parts)
lines.AddRange(boundary.GetLines(part.Location, direction));
return lines;
}
/// <summary>
/// Gets boundary lines for all parts in a pattern, with an additional
/// location offset applied. Avoids cloning the pattern.
/// </summary>
private static List<Line> GetOffsetPatternLines(Pattern pattern, Vector offset, PartBoundary boundary, PushDirection direction)
{
var lines = new List<Line>();
foreach (var part in pattern.Parts)
lines.AddRange(boundary.GetLines(part.Location + offset, direction));
return lines;
}
/// <summary>
/// Creates boundaries for all parts in a pattern. Parts that share the same
/// program geometry (same drawing and rotation) reuse the same boundary instance.
/// </summary>
private PartBoundary[] CreateBoundaries(Pattern pattern)
{
var boundaries = new PartBoundary[pattern.Parts.Count];
var cache = new List<(Drawing drawing, double rotation, PartBoundary boundary)>();
for (var i = 0; i < pattern.Parts.Count; i++)
{
var part = pattern.Parts[i];
PartBoundary found = null;
foreach (var entry in cache)
{
if (entry.drawing == part.BaseDrawing && entry.rotation.IsEqualTo(part.Rotation))
{
found = entry.boundary;
break;
}
}
if (found == null)
{
found = new PartBoundary(part, HalfSpacing);
cache.Add((part.BaseDrawing, part.Rotation, found));
}
boundaries[i] = found;
}
return boundaries;
}
/// <summary>
/// Tiles a pattern along the given axis, returning the cloned parts
/// (does not include the original pattern's parts). For multi-part
/// patterns, also adds individual parts from the next incomplete copy
/// that still fit within the work area.
/// </summary>
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, PartBoundary[] boundaries)
private List<Part> TilePattern(Pattern basePattern, NestDirection direction)
{
var copyDistance = FindPatternCopyDistance(basePattern, direction, boundaries);
var copyDistance = FindPatternCopyDistance(basePattern, direction);
if (copyDistance <= 0)
return new List<Part>();
@@ -394,11 +322,10 @@ namespace OpenNest.Engine.Fill
private List<Part> FillGrid(Pattern pattern, NestDirection direction)
{
var perpAxis = PerpendicularAxis(direction);
var boundaries = CreateBoundaries(pattern);
// Step 1: Tile along primary axis
var row = new List<Part>(pattern.Parts);
row.AddRange(TilePattern(pattern, direction, boundaries));
row.AddRange(TilePattern(pattern, direction));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1))
{
@@ -410,7 +337,7 @@ namespace OpenNest.Engine.Fill
// If primary tiling didn't produce copies, just tile along perpendicular
if (row.Count <= pattern.Parts.Count)
{
row.AddRange(TilePattern(pattern, perpAxis, boundaries));
row.AddRange(TilePattern(pattern, perpAxis));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
{
@@ -427,9 +354,8 @@ namespace OpenNest.Engine.Fill
rowPattern.Parts.AddRange(row);
rowPattern.UpdateBounds();
var rowBoundaries = CreateBoundaries(rowPattern);
var gridResult = new List<Part>(rowPattern.Parts);
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
gridResult.AddRange(TilePattern(rowPattern, perpAxis));
if (HasOverlappingParts(gridResult, out var a3, out var b3))
{
@@ -481,9 +407,8 @@ namespace OpenNest.Engine.Fill
return seed;
var template = seed.Parts[0];
var boundary = new PartBoundary(template, HalfSpacing);
var copyDistance = FindCopyDistance(template, direction, boundary);
var copyDistance = FindCopyDistance(template, direction);
if (copyDistance <= 0)
return seed;
+9 -6
View File
@@ -27,7 +27,10 @@ namespace OpenNest.Engine.ML
{
public static PartFeatures Extract(Drawing drawing)
{
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(drawing.Program)
// Normalize to canonical frame so features are invariant to import orientation.
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(canonical.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
@@ -45,18 +48,18 @@ namespace OpenNest.Engine.ML
var features = new PartFeatures
{
Area = drawing.Area,
Convexity = drawing.Area / (hullArea > 0 ? hullArea : 1.0),
Area = canonical.Area,
Convexity = canonical.Area / (hullArea > 0 ? hullArea : 1.0),
AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0),
BoundingBoxFill = drawing.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
BoundingBoxFill = canonical.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
VertexCount = polygon.Vertices.Count,
Bitmask = GenerateBitmask(polygon, 32)
};
// Circularity = 4 * PI * Area / Perimeter^2
var perimeterLen = polygon.Perimeter();
features.Circularity = (4 * System.Math.PI * drawing.Area) / (perimeterLen * perimeterLen);
features.PerimeterToAreaRatio = drawing.Area > 0 ? perimeterLen / drawing.Area : 0;
features.Circularity = (4 * System.Math.PI * canonical.Area) / (perimeterLen * perimeterLen);
features.PerimeterToAreaRatio = canonical.Area > 0 ? perimeterLen / canonical.Area : 0;
return features;
}
+35 -1
View File
@@ -334,6 +334,12 @@ namespace OpenNest
var bestFits = BestFitCache.GetOrCompute(
item.Drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
// BestFitCache stores pair coordinates in canonical frame. Build candidates
// from a canonical drawing copy so geometry and coords share a frame; rebind
// + un-rotate winning pair to the original drawing's frame before returning.
var canonicalDrawing = CanonicalFrame.AsCanonicalCopy(item.Drawing);
var sourceAngle = item.Drawing?.Source?.Angle ?? 0.0;
List<Part> bestPlacement = null;
Box bestTarget = null;
@@ -342,7 +348,7 @@ namespace OpenNest
if (!fit.Keep)
continue;
var parts = fit.BuildParts(item.Drawing);
var parts = fit.BuildParts(canonicalDrawing);
var pairBbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
var pairW = pairBbox.Width;
var pairL = pairBbox.Length;
@@ -374,6 +380,10 @@ namespace OpenNest
if (bestPlacement == null) continue;
// Rebind to the original drawing and compose sourceAngle onto rotation so the
// final placed parts sit in the user's visible frame.
bestPlacement = RebindPairToOriginal(bestPlacement, item.Drawing, sourceAngle);
result.AddRange(bestPlacement);
item.Quantity = 0;
@@ -388,6 +398,30 @@ namespace OpenNest
return result;
}
/// <summary>
/// Rebinds each canonical-frame Part in the pair to the original Drawing at its current
/// world pose, then composes sourceAngle onto each via CanonicalFrame.FromCanonical so
/// the returned list is in the original drawing's visible frame. Mirrors
/// DefaultNestEngine.RebindAndUnCanonicalize.
/// </summary>
private static List<Part> RebindPairToOriginal(List<Part> parts, Drawing original, double sourceAngle)
{
if (parts == null || parts.Count == 0)
return parts;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
var rebound = Part.CreateAtOrigin(original, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return CanonicalFrame.FromCanonical(parts, sourceAngle);
}
/// <summary>
/// Determines whether a drawing should use grid-fill (true) or bin-pack (false).
/// Low-quantity items whose total area is a small fraction of the plate are
+2 -2
View File
@@ -64,8 +64,8 @@ namespace OpenNest.Engine
var mbrArea = mbr.Area;
var mbrPerimeter = 2 * (mbr.Width + mbr.Height);
// Store primary angle (negated to align MBR with axes, same as RotationAnalysis).
result.PrimaryAngle = -mbr.Angle;
// Share the single angle formula with CanonicalAngle (no duplicate MBR compute).
result.PrimaryAngle = CanonicalAngle.FromMbr(mbr);
// Drawing perimeter for circularity and perimeter ratio.
var drawingPerimeter = polygon.Perimeter();
+1 -1
View File
@@ -15,7 +15,7 @@ namespace OpenNest.Engine.Strategies
public int PlateNumber { get; init; }
public CancellationToken Token { get; init; }
public IProgress<NestProgress> Progress { get; init; }
public FillPolicy Policy { get; init; }
public FillPolicy Policy { get; init; } = new FillPolicy(new DefaultFillComparer());
public int MaxQuantity { get; init; }
public PartType PartType { get; set; }
+7 -2
View File
@@ -42,6 +42,11 @@ namespace OpenNest.IO.Bom
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
dxfFiles[nameWithoutExt] = file;
}
foreach (var file in Directory.GetFiles(dxfFolder, "*.dwg"))
{
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
dxfFiles.TryAdd(nameWithoutExt, file);
}
}
// Partition items into: skipped, unmatched, or matched (grouped)
@@ -57,8 +62,8 @@ namespace OpenNest.IO.Bom
var lookupName = item.FileName;
// Strip .dxf extension if the BOM includes it
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase))
lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (!folderExists)
+7
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using ACadSharp;
using OpenNest.Bending;
using OpenNest.Geometry;
@@ -38,5 +39,11 @@ namespace OpenNest.IO
/// Default drawing name (filename without extension, unless overridden).
/// </summary>
public string Name { get; set; }
/// <summary>
/// The raw CAD document from the source file. Available for callers
/// that need access to non-geometry entities (e.g., text annotations).
/// </summary>
public CadDocument Document { get; set; }
}
}
+32
View File
@@ -5,6 +5,7 @@ using OpenNest.Bending;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO.Bending;
using OpenNest.Math;
namespace OpenNest.IO
{
@@ -25,6 +26,8 @@ namespace OpenNest.IO
var dxf = Dxf.Import(path);
RemoveDuplicateArcs(dxf.Entities);
var bends = new List<Bend>();
if (options.DetectBends && dxf.Document != null)
{
@@ -44,6 +47,7 @@ namespace OpenNest.IO
Bounds = dxf.Entities.GetBoundingBox(),
SourcePath = path,
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
Document = dxf.Document,
};
}
@@ -136,5 +140,33 @@ namespace OpenNest.IO
return drawing;
}
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);
}
}
}
+19 -4
View File
@@ -27,8 +27,7 @@ namespace OpenNest.IO
/// </summary>
public static DxfImportResult Import(string path)
{
using var reader = new DxfReader(path);
var doc = reader.Read();
var doc = ReadDocument(path);
return new DxfImportResult
{
@@ -41,8 +40,7 @@ namespace OpenNest.IO
{
try
{
using var reader = new DxfReader(path);
var doc = reader.Read();
var doc = ReadDocument(path);
return ConvertEntities(doc);
}
catch (Exception ex)
@@ -113,6 +111,23 @@ namespace OpenNest.IO
#region Private
private static bool IsDwg(string path) =>
Path.GetExtension(path).Equals(".dwg", StringComparison.OrdinalIgnoreCase);
private static CadDocument ReadDocument(string path)
{
if (IsDwg(path))
{
using var reader = new DwgReader(path);
return reader.Read();
}
else
{
using var reader = new DxfReader(path);
return reader.Read();
}
}
private static List<Entity> ConvertEntities(CadDocument doc)
{
var entities = new List<Entity>();
+12 -3
View File
@@ -181,13 +181,22 @@ namespace OpenNest.IO
{
var center = new Vector(ellipse.Center.X, ellipse.Center.Y);
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y);
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var semiMinor = semiMajor * ellipse.RadiusRatio;
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
var startParam = ellipse.StartParameter;
var endParam = ellipse.EndParameter;
if (ellipse.Normal.Z < 0)
{
var newStart = OpenNest.Math.Angle.TwoPI - endParam;
var newEnd = OpenNest.Math.Angle.TwoPI - startParam;
startParam = newStart;
endParam = newEnd;
}
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
var semiMinor = semiMajor * ellipse.RadiusRatio;
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
var layer = ellipse.Layer.ToOpenNest();
var color = ellipse.ResolveColor();
var lineTypeName = ellipse.ResolveLineTypeName();
+3
View File
@@ -4,6 +4,9 @@
<RootNamespace>OpenNest.IO</RootNamespace>
<AssemblyName>OpenNest.IO</AssemblyName>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
@@ -16,11 +16,16 @@ public sealed class CincinnatiPartSubprogramWriter
{
private readonly CincinnatiPostConfig _config;
private readonly CincinnatiFeatureWriter _featureWriter;
private readonly CoordinateFormatter _fmt;
private readonly Dictionary<int, int> _holeSubprograms;
public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config)
public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config,
Dictionary<int, int> holeSubprograms = null)
{
_config = config;
_featureWriter = new CincinnatiFeatureWriter(config);
_fmt = new CoordinateFormatter(config.PostedAccuracy);
_holeSubprograms = holeSubprograms;
}
/// <summary>
@@ -44,6 +49,15 @@ public sealed class CincinnatiPartSubprogramWriter
for (var i = 0; i < ordered.Count; i++)
{
var (codes, isEtch) = ordered[i];
var isLastFeature = i == ordered.Count - 1;
// SubProgramCall features are emitted as M98 hole calls
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
WriteHoleSubprogramCall(w, holeCall, i, isLastFeature);
continue;
}
var featureNumber = i == 0
? _config.FeatureLineNumberStart
: 1000 + i + 1;
@@ -55,7 +69,7 @@ public sealed class CincinnatiPartSubprogramWriter
FeatureNumber = featureNumber,
PartName = drawingName,
IsFirstFeatureOfPart = false,
IsLastFeatureOnSheet = i == ordered.Count - 1,
IsLastFeatureOnSheet = isLastFeature,
IsSafetyHeadraise = false,
IsExteriorFeature = false,
IsEtch = isEtch,
@@ -70,6 +84,30 @@ public sealed class CincinnatiPartSubprogramWriter
w.WriteLine($"M99 (END OF {drawingName})");
}
private void WriteHoleSubprogramCall(TextWriter w, SubProgramCall call,
int featureIndex, bool isLastFeature)
{
var postSubNum = _holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num)
? num : call.Id;
var featureNumber = featureIndex == 0
? _config.FeatureLineNumberStart
: 1000 + featureIndex + 1;
var sb = new StringBuilder();
if (_config.UseLineNumbers)
sb.Append($"N{featureNumber} ");
sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}");
w.WriteLine(sb.ToString());
w.WriteLine($"M98 P{postSubNum}");
w.WriteLine("G52 X0 Y0");
if (!isLastFeature)
w.WriteLine("M47");
}
/// <summary>
/// If the program has no leading rapid, inserts a synthetic rapid at the
/// last motion endpoint (the contour return point). This ensures the feature
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace OpenNest.Posts.Cincinnati
{
@@ -277,6 +279,24 @@ namespace OpenNest.Posts.Cincinnati
[DisplayName("Etch Libraries")]
[Description("Gas-to-library mapping for etch operations.")]
public List<EtchLibraryEntry> EtchLibraries { get; set; } = new();
[Category("B. Libraries")]
[DisplayName("Selected Library")]
[Description("Overrides Material/Thickness/Gas auto-resolution. Pick an existing entry from Material Libraries, or leave blank to auto-resolve.")]
[TypeConverter(typeof(MaterialLibraryNameConverter))]
public string SelectedLibrary { get; set; } = "";
public string FindBestLibrary(string materialName, double thickness)
{
if (MaterialLibraries == null || string.IsNullOrEmpty(materialName))
return "";
return MaterialLibraries
.Where(e => string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase))
.OrderBy(e => System.Math.Abs(e.Thickness - thickness))
.Select(e => e.Library)
.FirstOrDefault() ?? "";
}
}
public class MaterialLibraryEntry
@@ -9,7 +9,7 @@ using OpenNest.CNC;
namespace OpenNest.Posts.Cincinnati
{
public sealed class CincinnatiPostProcessor : IConfigurablePostProcessor
public sealed class CincinnatiPostProcessor : IConfigurablePostProcessor, IPostProcessorNestAware, IMaterialProvidingPostProcessor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -25,6 +25,23 @@ namespace OpenNest.Posts.Cincinnati
object IConfigurablePostProcessor.Config => Config;
public IEnumerable<string> GetMaterialNames()
{
if (Config?.MaterialLibraries == null)
return System.Array.Empty<string>();
return Config.MaterialLibraries
.Select(e => e.Material)
.Where(s => !string.IsNullOrWhiteSpace(s));
}
public void PrepareForNest(Nest nest)
{
var materialName = nest?.Material?.Name ?? "";
var thickness = nest?.Thickness ?? 0.0;
Config.SelectedLibrary = Config.FindBestLibrary(materialName, thickness);
}
public CincinnatiPostProcessor()
{
var configPath = GetConfigPath();
@@ -128,7 +145,8 @@ namespace OpenNest.Posts.Cincinnati
// Part sub-programs (if enabled)
if (subprogramEntries != null)
{
var partSubWriter = new CincinnatiPartSubprogramWriter(Config);
var partSubWriter = new CincinnatiPartSubprogramWriter(Config,
holeMapping.Count > 0 ? holeMapping : null);
var sheetDiagonal = firstPlate != null
? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width
+ firstPlate.Size.Length * firstPlate.Size.Length)
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace OpenNest.Posts.Cincinnati
{
public sealed class MaterialLibraryNameConverter : StringConverter
{
public override bool GetStandardValuesSupported(ITypeDescriptorContext context) => true;
public override bool GetStandardValuesExclusive(ITypeDescriptorContext context) => false;
public override StandardValuesCollection GetStandardValues(ITypeDescriptorContext context)
{
var config = context?.Instance as CincinnatiPostConfig;
var names = new List<string> { "" };
if (config?.MaterialLibraries != null)
{
names.AddRange(config.MaterialLibraries
.Select(e => e.Library)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
}
return new StandardValuesCollection(names);
}
}
}
@@ -10,15 +10,20 @@ public sealed class MaterialLibraryResolver
private readonly List<MaterialLibraryEntry> _materialLibraries;
private readonly List<EtchLibraryEntry> _etchLibraries;
private readonly string _selectedLibrary;
public MaterialLibraryResolver(CincinnatiPostConfig config)
{
_materialLibraries = config.MaterialLibraries ?? new List<MaterialLibraryEntry>();
_etchLibraries = config.EtchLibraries ?? new List<EtchLibraryEntry>();
_selectedLibrary = config.SelectedLibrary ?? "";
}
public string ResolveCutLibrary(string materialName, double thickness, string gas)
{
if (!string.IsNullOrEmpty(_selectedLibrary))
return EnsureLibExtension(_selectedLibrary);
var entry = _materialLibraries.FirstOrDefault(e =>
string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase) &&
System.Math.Abs(e.Thickness - thickness) <= ThicknessTolerance &&
@@ -6,11 +6,19 @@
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="OpenNest.Posts.Cincinnati.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="CopyToPostsDir" AfterTargets="Build">
<PropertyGroup>
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
<ConfigJson>$(MSBuildProjectDirectory)\OpenNest.Posts.Cincinnati.json</ConfigJson>
<DeployedConfigJson>$(PostsDir)OpenNest.Posts.Cincinnati.json</DeployedConfigJson>
</PropertyGroup>
<MakeDir Directories="$(PostsDir)" />
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
<Copy SourceFiles="$(ConfigJson)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" Condition="!Exists('$(DeployedConfigJson)')" />
</Target>
</Project>
@@ -0,0 +1,163 @@
{
"ConfigurationName": "CL940",
"PostedUnits": "Inches",
"PostedAccuracy": 4,
"UseLineNumbers": true,
"FeatureLineNumberStart": 1,
"UseSheetSubprograms": true,
"SheetSubprogramStart": 101,
"UsePartSubprograms": false,
"PartSubprogramStart": 200,
"VariableDeclarationSubprogram": 100,
"CoordModeBetweenParts": "G92",
"ProcessParameterMode": "LibraryFile",
"DefaultAssistGas": "O2",
"DefaultEtchGas": "N2",
"UseExactStopMode": false,
"UseSpeedGas": false,
"UseAntiDive": true,
"UseSmartRapids": false,
"KerfCompensation": "ControllerSide",
"DefaultKerfSide": "Left",
"InteriorM47": "Always",
"ExteriorM47": "Always",
"M47OverrideDistanceThreshold": null,
"SafetyHeadraiseDistance": 2000,
"PalletExchange": "EndOfSheet",
"LeadInFeedratePercent": 0.5,
"LeadInArcLine2FeedratePercent": 0.5,
"LeadOutFeedratePercent": 0.5,
"CircleFeedrateMultiplier": 0.8,
"ArcFeedrate": "None",
"ArcFeedrateRanges": [
{ "MaxRadius": 0.125, "FeedratePercent": 0.25, "VariableNumber": 123 },
{ "MaxRadius": 0.75, "FeedratePercent": 0.5, "VariableNumber": 124 },
{ "MaxRadius": 4.5, "FeedratePercent": 0.8, "VariableNumber": 125 }
],
"UserVariableStart": 200,
"SheetWidthVariable": 110,
"SheetLengthVariable": 111,
"MaterialLibraries": [
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "AIR", "Library": "AL032AIR" },
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "N2", "Library": "AL032N2" },
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "O2", "Library": "AL032O2" },
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "AIR", "Library": "AL050AIR" },
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "N2", "Library": "AL050N2" },
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "O2", "Library": "AL050O2" },
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "AIR", "Library": "AL063AIR" },
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "N2", "Library": "AL063N2" },
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "O2", "Library": "AL063O2" },
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "AIR", "Library": "AL080AIR" },
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "N2", "Library": "AL080N2" },
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "O2", "Library": "AL080O2" },
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "AIR", "Library": "AL090AIR" },
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "N2", "Library": "AL090N2" },
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "O2", "Library": "AL090O2" },
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "AIR", "Library": "AL100AIR" },
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "N2", "Library": "AL100N2" },
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "O2", "Library": "AL100O2" },
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "AIR", "Library": "AL125AIR" },
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "N2", "Library": "AL125N2" },
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "O2", "Library": "AL125O2" },
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "AIR", "Library": "AL190AIR" },
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "N2", "Library": "AL190N2" },
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "O2", "Library": "AL190O2" },
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "AIR", "Library": "AL250AIR" },
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "N2", "Library": "AL250N2" },
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "O2", "Library": "AL250O2" },
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "AIR", "Library": "AL375AIR" },
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "N2", "Library": "AL375N2" },
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "O2", "Library": "AL375O2" },
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "AIR", "Library": "AL500AIR" },
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "N2", "Library": "AL500N2" },
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "O2", "Library": "AL500O2" },
{ "Material": "Aluminum", "Thickness": 0.625, "Gas": "N2", "Library": "AL625N2" },
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "AIR", "Library": "AL750AIR" },
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "N2", "Library": "AL750N2" },
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "O2", "Library": "AL750O2" },
{ "Material": "Aluminum", "Thickness": 1.000, "Gas": "AIR", "Library": "AL1000AIR" },
{ "Material": "Aluminum", "Thickness": 1.000, "Gas": "N2", "Library": "AL1000N2" },
{ "Material": "Galvanized Steel", "Thickness": 0.135, "Gas": "N2", "Library": "GALV135N2" },
{ "Material": "Galvanized Steel", "Thickness": 0.188, "Gas": "N2", "Library": "GALV188N2" },
{ "Material": "Carbon Steel", "Thickness": 0.036, "Gas": "AIR", "Library": "MS036AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.036, "Gas": "N2", "Library": "MS036N2" },
{ "Material": "Carbon Steel", "Thickness": 0.048, "Gas": "AIR", "Library": "MS048AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.048, "Gas": "N2", "Library": "MS048N2" },
{ "Material": "Carbon Steel", "Thickness": 0.060, "Gas": "AIR", "Library": "MS060AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.060, "Gas": "N2", "Library": "MS060N2" },
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "AIR", "Library": "MS075AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "N2", "Library": "MS075N2" },
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "N2", "Library": "MS075N2FE" },
{ "Material": "Carbon Steel", "Thickness": 0.090, "Gas": "N2", "Library": "MS090N2" },
{ "Material": "Carbon Steel", "Thickness": 0.105, "Gas": "AIR", "Library": "MS105AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.105, "Gas": "N2", "Library": "MS105N2" },
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "AIR", "Library": "MS120AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "N2", "Library": "MS120N2" },
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "N2", "Library": "MS120N2FE" },
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "AIR", "Library": "MS135AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2" },
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2FE" },
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2Panel" },
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "AIR", "Library": "MS188AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "N2", "Library": "MS188N2" },
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "N2", "Library": "MS188N2FLOORPLATE" },
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "O2", "Library": "MS188O2" },
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "AIR", "Library": "MS250AIR" },
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "N2", "Library": "MS250N2" },
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "N2", "Library": "MS250N2FLOORPLATE" },
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "O2", "Library": "MS250O2" },
{ "Material": "Carbon Steel", "Thickness": 0.313, "Gas": "O2", "Library": "MS313O2" },
{ "Material": "Carbon Steel", "Thickness": 0.375, "Gas": "O2", "Library": "MS375O2" },
{ "Material": "Carbon Steel", "Thickness": 0.500, "Gas": "N2", "Library": "MS500N2" },
{ "Material": "Carbon Steel", "Thickness": 0.500, "Gas": "O2", "Library": "MS500O2" },
{ "Material": "Carbon Steel", "Thickness": 0.625, "Gas": "O2", "Library": "MS625O2" },
{ "Material": "Carbon Steel", "Thickness": 0.750, "Gas": "O2", "Library": "MS750O2" },
{ "Material": "Carbon Steel", "Thickness": 1.000, "Gas": "O2", "Library": "MS1000O2" },
{ "Material": "Stainless Steel", "Thickness": 0.036, "Gas": "AIR", "Library": "SS036AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.036, "Gas": "N2", "Library": "SS036N2" },
{ "Material": "Stainless Steel", "Thickness": 0.048, "Gas": "AIR", "Library": "SS048AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.048, "Gas": "N2", "Library": "SS048N2" },
{ "Material": "Stainless Steel", "Thickness": 0.060, "Gas": "AIR", "Library": "SS060AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.060, "Gas": "N2", "Library": "SS060N2" },
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "AIR", "Library": "SS075AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "N2", "Library": "SS075N2" },
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "N2", "Library": "SS075N2FE" },
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "AIR", "Library": "SS105AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "N2", "Library": "SS105N2" },
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "N2", "Library": "SS105N2FE" },
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "AIR", "Library": "SS120AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "N2", "Library": "SS120N2" },
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "N2", "Library": "SS120N2FE" },
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "AIR", "Library": "SS135AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "N2", "Library": "SS135N2" },
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "N2", "Library": "SS135N2FE" },
{ "Material": "Stainless Steel", "Thickness": 0.188, "Gas": "AIR", "Library": "SS188AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.188, "Gas": "N2", "Library": "SS188N2" },
{ "Material": "Stainless Steel", "Thickness": 0.250, "Gas": "AIR", "Library": "SS250AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.250, "Gas": "N2", "Library": "SS250N2" },
{ "Material": "Stainless Steel", "Thickness": 0.313, "Gas": "N2", "Library": "SS313N2" },
{ "Material": "Stainless Steel", "Thickness": 0.375, "Gas": "AIR", "Library": "SS375AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.375, "Gas": "N2", "Library": "SS375N2" },
{ "Material": "Stainless Steel", "Thickness": 0.500, "Gas": "AIR", "Library": "SS500AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.500, "Gas": "N2", "Library": "SS500N2" },
{ "Material": "Stainless Steel", "Thickness": 0.625, "Gas": "N2", "Library": "SS625N2" },
{ "Material": "Stainless Steel", "Thickness": 0.750, "Gas": "AIR", "Library": "SS750AIR" },
{ "Material": "Stainless Steel", "Thickness": 0.750, "Gas": "N2", "Library": "SS750N2" },
{ "Material": "Stainless Steel", "Thickness": 1.000, "Gas": "AIR", "Library": "SS1000AIR" },
{ "Material": "Stainless Steel", "Thickness": 1.000, "Gas": "N2", "Library": "SS1000N2" },
{ "Material": "Phenolic", "Thickness": 0.0, "Gas": "", "Library": "Phenolic" },
{ "Material": "Gasket", "Thickness": 0.250, "Gas": "N2", "Library": "GASKET250N2" }
],
"EtchLibraries": [
{ "Gas": "AIR", "Library": "EtchAIR" },
{ "Gas": "N2", "Library": "EtchN2" },
{ "Gas": "N2", "Library": "EtchN2_fast" },
{ "Gas": "N2", "Library": "Etchn2_no_mark_pvc" },
{ "Gas": "O2", "Library": "EtchO2" },
{ "Gas": "O2", "Library": "ETCHO2FINE" }
]
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.CNC
{
public class RapidEnumeratorTests
{
[Fact]
public void Enumerate_AbsoluteProgram_OffsetsMotionsByBasePos()
{
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new RapidMove(1, 0));
pgm.Codes.Add(new LinearMove(2, 0));
pgm.Codes.Add(new RapidMove(3, 3));
var segments = RapidEnumerator.Enumerate(pgm, basePos: new Vector(100, 200), startPos: new Vector(0, 0));
// Origin → first pierce, then interior rapid from contour end to next rapid target.
Assert.Equal(2, segments.Count);
Assert.Equal(new Vector(0, 0), segments[0].From);
Assert.Equal(new Vector(101, 200), segments[0].To);
Assert.Equal(new Vector(102, 200), segments[1].From);
Assert.Equal(new Vector(103, 203), segments[1].To);
}
[Fact]
public void Enumerate_IncrementalProgram_InterpretsDeltasFromBasePos()
{
// Pre-lead-in raw program: first rapid normalized to (0,0), Mode=Incremental
// (matches ConvertGeometry.ToProgram output).
var pgm = new Program(Mode.Incremental);
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(5, 0));
pgm.Codes.Add(new LinearMove(0, 5));
pgm.Codes.Add(new RapidMove(1, 1));
var segments = RapidEnumerator.Enumerate(pgm, basePos: new Vector(100, 200), startPos: new Vector(0, 0));
Assert.Equal(2, segments.Count);
// First rapid: plate origin → part pierce at basePos.
Assert.Equal(new Vector(0, 0), segments[0].From);
Assert.Equal(new Vector(100, 200), segments[0].To);
// Interior rapid: after deltas (5,0) and (0,5) from basePos, rapid delta (1,1).
Assert.Equal(new Vector(105, 205), segments[1].From);
Assert.Equal(new Vector(106, 206), segments[1].To);
}
[Fact]
public void Enumerate_SubProgramCall_RapidEndsAtAbsoluteHolePierce()
{
// Main program: lead-in rapid, a line, then a SubProgramCall for a hole.
// Sub-program (incremental) starts with RapidMove(radius, 0) to the hole pierce.
var sub = new Program(Mode.Incremental);
sub.Codes.Add(new RapidMove(0.5, 0));
sub.Codes.Add(new LinearMove(0, 0.1));
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new RapidMove(0.2, 0.3)); // first pierce (perimeter lead-in)
pgm.Codes.Add(new LinearMove(1.0, 1.0)); // contour move
pgm.Codes.Add(new SubProgramCall
{
Id = 1,
Program = sub,
Offset = new Vector(2, 2), // hole center (drawing-local)
});
var basePos = new Vector(100, 200); // part.Location
var segments = RapidEnumerator.Enumerate(pgm, basePos, startPos: new Vector(0, 0));
// Expected rapids:
// 1. origin → first pierce (0.2+100, 0.3+200) = (100.2, 200.3)
// 2. end of contour (1+100, 1+200) = (101, 201) → hole pierce (2+100+0.5, 2+200) = (102.5, 202)
// The sub's internal first rapid is skipped (already drawn in #2).
Assert.Equal(2, segments.Count);
Assert.Equal(new Vector(0, 0), segments[0].From);
Assert.Equal(new Vector(100.2, 200.3), segments[0].To);
Assert.Equal(new Vector(101, 201), segments[1].From);
Assert.Equal(new Vector(102.5, 202), segments[1].To);
}
}
}
@@ -0,0 +1,156 @@
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Engine;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests.Engine;
public class CanonicalAngleTests
{
private const double AngleTol = 0.002; // ~0.11°
private static Drawing MakeRect(double w, double h)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Drawing("rect", pgm);
}
private static Drawing RotateCopy(Drawing src, double angle)
{
var pgm = src.Program.Clone() as OpenNest.CNC.Program;
pgm.Rotate(angle, pgm.BoundingBox().Center);
return new Drawing("rotated", pgm);
}
[Fact]
public void AxisAlignedRectangle_ReturnsZero()
{
var d = MakeRect(100, 50);
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
}
// Program.BoundingBox() has a pre-existing bug where minX/minY initialize to 0 and can
// only decrease, so programs whose extents stay in the positive half-plane report a
// too-large AABB. To validate MBR-axis-alignment without tripping that bug, extract the
// outer perimeter polygon and compute its true AABB from vertices.
private static (double length, double width) TrueAabb(OpenNest.CNC.Program pgm)
{
var entities = ConvertProgram.ToGeometry(pgm).Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = ShapeBuilder.GetShapes(entities);
var outer = shapes.OrderByDescending(s => s.Area()).First();
var poly = outer.ToPolygonWithTolerance(0.1);
var minX = poly.Vertices.Min(v => v.X);
var maxX = poly.Vertices.Max(v => v.X);
var minY = poly.Vertices.Min(v => v.Y);
var maxY = poly.Vertices.Max(v => v.Y);
return (maxX - minX, maxY - minY);
}
[Theory]
[InlineData(0.3)]
[InlineData(0.7)]
[InlineData(1.2)]
public void Rectangle_ReturnsNegatedRotation_Modulo90(double theta)
{
var rotated = RotateCopy(MakeRect(100, 50), theta);
var angle = CanonicalAngle.Compute(rotated);
// Applying the returned angle should leave MBR axis-aligned.
var canonical = rotated.Program.Clone() as OpenNest.CNC.Program;
canonical.Rotate(angle, canonical.BoundingBox().Center);
var (length, width) = TrueAabb(canonical);
var longer = System.Math.Max(length, width);
var shorter = System.Math.Min(length, width);
Assert.InRange(longer, 100 - 0.1, 100 + 0.1);
Assert.InRange(shorter, 50 - 0.1, 50 + 0.1);
}
[Fact]
public void NearZeroInput_SnapsToZero()
{
var rotated = RotateCopy(MakeRect(100, 50), 0.0005);
Assert.Equal(0.0, CanonicalAngle.Compute(rotated), precision: 6);
}
[Fact]
public void DegeneratePolygon_ReturnsZero()
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
var d = new Drawing("line", pgm);
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
}
[Fact]
public void EmptyProgram_ReturnsZero()
{
var d = new Drawing("empty", new OpenNest.CNC.Program());
Assert.Equal(0.0, CanonicalAngle.Compute(d), precision: 6);
}
}
public class DrawingCanonicalAngleWiringTests
{
private static OpenNest.CNC.Program RotatedRectProgram(double w, double h, double theta)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
if (!OpenNest.Math.Tolerance.IsEqualTo(theta, 0))
pgm.Rotate(theta, pgm.BoundingBox().Center);
return pgm;
}
[Fact]
public void Constructor_ComputesAngleOnProgramAssignment()
{
var pgm = RotatedRectProgram(100, 50, 0.5);
var d = new Drawing("r", pgm);
Assert.InRange(d.Source.Angle, -0.52, -0.48);
}
[Fact]
public void SetProgram_RecomputesAngle()
{
var d = new Drawing("r", RotatedRectProgram(100, 50, 0.0));
Assert.Equal(0.0, d.Source.Angle, precision: 6);
d.Program = RotatedRectProgram(100, 50, 0.5);
Assert.InRange(d.Source.Angle, -0.52, -0.48);
}
[Fact]
public void IsCutOff_SkipsAngleComputation()
{
var d = new Drawing("cut", RotatedRectProgram(100, 50, 0.5)) { IsCutOff = true };
// Re-assign after flag is set so the setter observes IsCutOff.
d.Program = RotatedRectProgram(100, 50, 0.5);
Assert.Equal(0.0, d.Source.Angle, precision: 6);
}
[Fact]
public void RecomputeCanonicalAngle_UpdatesAfterMutation()
{
var d = new Drawing("r", RotatedRectProgram(100, 50, 0.0));
Assert.Equal(0.0, d.Source.Angle, precision: 6);
// Mutate in-place (doesn't trigger setter).
d.Program.Rotate(0.5, d.Program.BoundingBox().Center);
Assert.Equal(0.0, d.Source.Angle, precision: 6); // still stale
d.RecomputeCanonicalAngle();
Assert.InRange(d.Source.Angle, -0.52, -0.48);
}
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Engine;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests.Engine;
public class CanonicalFrameTests
{
private static Drawing MakeRect(double w, double h, double rotation)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
if (!Tolerance.IsEqualTo(rotation, 0))
pgm.Rotate(rotation, pgm.BoundingBox().Center);
return new Drawing("rect", pgm) { Source = new SourceInfo { Angle = -rotation } };
}
[Fact]
public void AsCanonicalCopy_AxisAlignsMbr()
{
var d = MakeRect(100, 50, 0.6);
var canonical = CanonicalFrame.AsCanonicalCopy(d);
var bb = canonical.Program.BoundingBox();
var longer = System.Math.Max(bb.Length, bb.Width);
var shorter = System.Math.Min(bb.Length, bb.Width);
Assert.InRange(longer, 100 - 0.1, 100 + 0.1);
Assert.InRange(shorter, 50 - 0.1, 50 + 0.1);
Assert.Equal(0.0, canonical.Source.Angle, precision: 6);
}
[Fact]
public void AsCanonicalCopy_DoesNotMutateSource()
{
var d = MakeRect(100, 50, 0.6);
var originalBbox = d.Program.BoundingBox();
var originalAngle = d.Source.Angle;
CanonicalFrame.AsCanonicalCopy(d);
var afterBbox = d.Program.BoundingBox();
Assert.Equal(originalBbox.Width, afterBbox.Width, precision: 6);
Assert.Equal(originalBbox.Length, afterBbox.Length, precision: 6);
Assert.Equal(originalAngle, d.Source.Angle, precision: 6);
}
[Fact]
public void FromCanonical_ComposesSourceAngleOntoRotation()
{
var d = MakeRect(100, 50, 0.0);
var part = new Part(d);
part.Rotate(0.2); // engine returned a canonical-frame part at R = 0.2
var placed = CanonicalFrame.FromCanonical(new List<Part> { part }, sourceAngle: -0.5);
// R' = R + sourceAngle = 0.2 + (-0.5) = -0.3
// Part.Rotation comes from Program.Rotation which is normalized to [0, 2PI),
// so compare after normalizing the expected value as well.
Assert.Single(placed);
Assert.Equal(Angle.NormalizeRad(-0.3), placed[0].Rotation, precision: 4);
}
[Fact]
public void RoundTrip_RestoresGeometry()
{
var d = MakeRect(100, 50, 0.4);
var canonical = CanonicalFrame.AsCanonicalCopy(d);
// Place a part at origin in the canonical frame.
var part = Part.CreateAtOrigin(canonical);
var canonicalBbox = part.BoundingBox;
var placed = CanonicalFrame.FromCanonical(new List<Part> { part }, d.Source.Angle);
var originalBbox = d.Program.BoundingBox();
Assert.Equal(originalBbox.Width, placed[0].BoundingBox.Width, precision: 2);
Assert.Equal(originalBbox.Length, placed[0].BoundingBox.Length, precision: 2);
}
}
@@ -0,0 +1,84 @@
using OpenNest.CNC;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Threading;
namespace OpenNest.Tests.Engine;
public class NestInvarianceTests
{
private static OpenNest.CNC.Program MakeLShapedProgram()
{
// L-shape: 100x50 outer rect with a 50x30 notch removed from top-right.
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 0)));
pgm.Codes.Add(new LinearMove(new Vector(100, 20)));
pgm.Codes.Add(new LinearMove(new Vector(50, 20)));
pgm.Codes.Add(new LinearMove(new Vector(50, 50)));
pgm.Codes.Add(new LinearMove(new Vector(0, 50)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return pgm;
}
private static Drawing MakeImportedAt(double rotation)
{
var pgm = MakeLShapedProgram();
if (!Tolerance.IsEqualTo(rotation, 0))
pgm.Rotate(rotation, pgm.BoundingBox().Center);
return new Drawing("L", pgm);
}
private static Plate MakePlate() => new Plate(new Size(500, 500))
{
Quadrant = 1,
PartSpacing = 2,
};
private static int RunFillCount(Drawing drawing, Plate plate)
{
BestFitCache.Clear();
var engine = new DefaultNestEngine(plate);
var item = new NestItem { Drawing = drawing };
var parts = engine.Fill(item, plate.WorkArea(), progress: null, token: CancellationToken.None);
return parts?.Count ?? 0;
}
[Theory]
[InlineData(0.0)]
[InlineData(0.3)]
[InlineData(0.8)]
[InlineData(1.2)]
public void Fill_SameCount_AcrossImportOrientations(double theta)
{
var baseline = RunFillCount(MakeImportedAt(0.0), MakePlate());
var rotated = RunFillCount(MakeImportedAt(theta), MakePlate());
// Allow +/-1 tolerance for sweep quantization edge effects near plate boundaries.
Assert.InRange(rotated, baseline - 1, baseline + 1);
}
[Fact]
public void Fill_PlacedPartsStayWithinWorkArea_AcrossImportOrientations()
{
var plate = MakePlate();
var workArea = plate.WorkArea();
foreach (var theta in new[] { 0.0, 0.3, 0.8, 1.2 })
{
BestFitCache.Clear();
var engine = new DefaultNestEngine(plate);
var item = new NestItem { Drawing = MakeImportedAt(theta) };
var parts = engine.Fill(item, workArea, progress: null, token: CancellationToken.None);
Assert.NotNull(parts);
foreach (var p in parts)
{
Assert.InRange(p.BoundingBox.Left, workArea.Left - 0.5, workArea.Right + 0.5);
Assert.InRange(p.BoundingBox.Bottom, workArea.Bottom - 0.5, workArea.Top + 0.5);
}
}
}
}
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Geometry;
public class BoxComparisonTests
{
[Fact]
public void GreaterThan_TallerBox_ReturnsTrue()
{
var tall = new Box(0, 0, 10, 20);
var short_ = new Box(0, 0, 10, 10);
Assert.True(tall > short_);
Assert.False(short_ > tall);
}
[Fact]
public void GreaterThan_SameWidthLongerBox_ReturnsTrue()
{
var longer = new Box(0, 0, 20, 10);
var shorter = new Box(0, 0, 10, 10);
Assert.True(longer > shorter);
Assert.False(shorter > longer);
}
[Fact]
public void LessThan_ShorterBox_ReturnsTrue()
{
var tall = new Box(0, 0, 10, 20);
var short_ = new Box(0, 0, 10, 10);
Assert.True(short_ < tall);
Assert.False(tall < short_);
}
[Fact]
public void GreaterThanOrEqual_EqualBoxes_ReturnsTrue()
{
var a = new Box(0, 0, 10, 20);
var b = new Box(0, 0, 10, 20);
Assert.True(a >= b);
Assert.True(b >= a);
}
[Fact]
public void LessThanOrEqual_EqualBoxes_ReturnsTrue()
{
var a = new Box(0, 0, 10, 20);
var b = new Box(0, 0, 10, 20);
Assert.True(a <= b);
Assert.True(b <= a);
}
[Fact]
public void CompareTo_TallerBox_ReturnsPositive()
{
var tall = new Box(0, 0, 10, 20);
var short_ = new Box(0, 0, 10, 10);
Assert.True(tall.CompareTo(short_) > 0);
Assert.True(short_.CompareTo(tall) < 0);
}
[Fact]
public void CompareTo_EqualBoxes_ReturnsZero()
{
var a = new Box(0, 0, 10, 20);
var b = new Box(0, 0, 10, 20);
Assert.Equal(0, a.CompareTo(b));
}
[Fact]
public void Sort_OrdersByWidthThenLength()
{
var boxes = new List<Box>
{
new Box(0, 0, 20, 10),
new Box(0, 0, 5, 30),
new Box(0, 0, 10, 10),
};
boxes.Sort();
Assert.Equal(10, boxes[0].Width);
Assert.Equal(10, boxes[0].Length);
Assert.Equal(10, boxes[1].Width);
Assert.Equal(20, boxes[1].Length);
Assert.Equal(30, boxes[2].Width);
}
}
@@ -1,14 +1,19 @@
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math;
using Xunit;
using Xunit.Abstractions;
using System.Linq;
namespace OpenNest.Tests.Geometry;
public class EllipseConverterTests
{
private readonly ITestOutputHelper _output;
private const double Tol = 1e-10;
public EllipseConverterTests(ITestOutputHelper output) => _output = output;
[Fact]
public void EvaluatePoint_AtZero_ReturnsMajorAxisEnd()
{
@@ -244,6 +249,101 @@ public class EllipseConverterTests
}
}
[Fact]
public void DxfImport_ArcBoundingBoxes_Diagnostic()
{
var path = @"C:\Users\aisaacs\Desktop\11ga tab.dxf";
if (!System.IO.File.Exists(path)) return;
var result = Dxf.Import(path);
var all = (System.Collections.Generic.IEnumerable<IBoundable>)result.Entities;
var bbox = all.GetBoundingBox();
_output.WriteLine($"Overall: X={bbox.X:F4} Y={bbox.Y:F4} W={bbox.Length:F4} H={bbox.Width:F4}");
for (var i = 0; i < result.Entities.Count; i++)
{
var e = result.Entities[i];
var b = e.BoundingBox;
var flag = (b.Length > 1 || b.Width > 1) ? " ***" : "";
_output.WriteLine($"{i + 1,3}. {e.GetType().Name,-8} X={b.X:F4} Y={b.Y:F4} W={b.Length:F4} H={b.Width:F4}{flag}");
}
}
[Fact]
public void ToOpenNest_FlippedNormalZ_ProducesCorrectArcs()
{
var normal = new ACadSharp.Entities.Ellipse
{
Center = new CSMath.XYZ(-0.275, -0.245, 0),
MajorAxisEndPoint = new CSMath.XYZ(0.0001, 1.245, 0),
RadiusRatio = 0.28,
StartParameter = 0.017,
EndParameter = 1.571,
Normal = new CSMath.XYZ(0, 0, 1)
};
var flipped = new ACadSharp.Entities.Ellipse
{
Center = new CSMath.XYZ(0.275, -0.245, 0),
MajorAxisEndPoint = new CSMath.XYZ(-0.0001, 1.245, 0),
RadiusRatio = 0.28,
StartParameter = 0.017,
EndParameter = 1.571,
Normal = new CSMath.XYZ(0, 0, -1)
};
var normalArcs = normal.ToOpenNest();
var flippedArcs = flipped.ToOpenNest();
Assert.True(normalArcs.Count > 0);
Assert.True(flippedArcs.Count > 0);
Assert.True(normalArcs.All(e => e is Arc));
Assert.True(flippedArcs.All(e => e is Arc));
var normalFirst = (Arc)normalArcs.First();
var flippedFirst = (Arc)flippedArcs.First();
var normalStart = GetArcStart(normalFirst);
var flippedStart = GetArcStart(flippedFirst);
Assert.True(normalStart.X < 0, $"Normal ellipse start X should be negative, got {normalStart.X}");
Assert.True(flippedStart.X > 0, $"Flipped ellipse should bulge right, got {flippedStart.X}");
var normalBbox = GetBoundingBox(normalArcs.Cast<Arc>());
var flippedBbox = GetBoundingBox(flippedArcs.Cast<Arc>());
Assert.True(flippedBbox.minX > 0, $"Flipped ellipse should stay on positive X side, minX={flippedBbox.minX}");
Assert.True(normalBbox.maxX < 0, $"Normal ellipse should stay on negative X side, maxX={normalBbox.maxX}");
}
private static (double minX, double maxX) GetBoundingBox(IEnumerable<Arc> arcs)
{
var minX = double.MaxValue;
var maxX = double.MinValue;
foreach (var arc in arcs)
{
var s = GetArcStart(arc);
var e = GetArcEnd(arc);
minX = System.Math.Min(minX, System.Math.Min(s.X, e.X));
maxX = System.Math.Max(maxX, System.Math.Max(s.X, e.X));
}
return (minX, maxX);
}
private static Vector GetArcStart(Arc arc)
{
var angle = arc.IsReversed ? arc.EndAngle : arc.StartAngle;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
}
private static Vector GetArcEnd(Arc arc)
{
var angle = arc.IsReversed ? arc.StartAngle : arc.EndAngle;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(angle),
arc.Center.Y + arc.Radius * System.Math.Sin(angle));
}
private static double MaxDeviationFromEllipse(Arc arc, Vector ellipseCenter,
double semiMajor, double semiMinor, double rotation, int samples)
{
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using OpenNest.Geometry;
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests.Geometry;
public class WeldEndpointsTests
{
[Fact]
public void WeldEndpoints_SnapsNearbyLineEndpoints()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10.0000005, 0, 20, 0);
var entities = new List<Entity> { line1, line2 };
ShapeBuilder.WeldEndpoints(entities, 0.000001);
Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) <= Tolerance.Epsilon);
}
[Fact]
public void WeldEndpoints_SnapsArcEndpointByAdjustingAngle()
{
var line = new Line(0, 0, 10, 0);
var arc = new Arc(15, 0, 5, Angle.ToRadians(180.001), Angle.ToRadians(90));
var entities = new List<Entity> { line, arc };
ShapeBuilder.WeldEndpoints(entities, 0.01);
var arcStart = arc.StartPoint();
Assert.True(line.EndPoint.DistanceTo(arcStart) <= 0.01);
}
[Fact]
public void WeldEndpoints_DoesNotWeldDistantEndpoints()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10.1, 0, 20, 0);
var entities = new List<Entity> { line1, line2 };
ShapeBuilder.WeldEndpoints(entities, 0.000001);
Assert.True(line1.EndPoint.DistanceTo(line2.StartPoint) > 0.01);
}
[Fact]
public void GetShapes_WithWeldTolerance_WeldsBeforeChaining()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10.0000005, 0, 10.0000005, 10);
var entities = new List<Entity> { line1, line2 };
var shapes = ShapeBuilder.GetShapes(entities, weldTolerance: 0.000001);
Assert.Single(shapes);
Assert.Equal(2, shapes[0].Entities.Count);
}
[Fact]
public void GetShapes_WithoutWeldTolerance_DefaultBehavior()
{
var line1 = new Line(0, 0, 10, 0);
var line2 = new Line(10, 0, 10, 10);
var entities = new List<Entity> { line1, line2 };
var shapes = ShapeBuilder.GetShapes(entities);
Assert.Single(shapes);
Assert.Equal(2, shapes[0].Entities.Count);
}
}
@@ -0,0 +1,96 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests.IO;
public class RemoveDuplicateArcsTests
{
[Fact]
public void RemoveDuplicateArcs_RemovesArcMatchingCircle_SameLayer()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer };
var line = new Line(0, 0, 10, 0) { Layer = layer };
var entities = new List<Entity> { circle, arc, line };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
Assert.Contains(circle, entities);
Assert.Contains(line, entities);
Assert.DoesNotContain(arc, entities);
}
[Fact]
public void RemoveDuplicateArcs_KeepsArcOnDifferentLayer()
{
var layer1 = new Layer("cut");
var layer2 = new Layer("etch");
var circle = new Circle(10, 10, 5) { Layer = layer1 };
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer2 };
var entities = new List<Entity> { circle, arc };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
Assert.Contains(arc, entities);
}
[Fact]
public void RemoveDuplicateArcs_KeepsArcWithDifferentRadius()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc = new Arc(10, 10, 3, 0, Angle.ToRadians(90)) { Layer = layer };
var entities = new List<Entity> { circle, arc };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
}
[Fact]
public void RemoveDuplicateArcs_KeepsArcWithDifferentCenter()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc = new Arc(20, 20, 5, 0, Angle.ToRadians(90)) { Layer = layer };
var entities = new List<Entity> { circle, arc };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
}
[Fact]
public void RemoveDuplicateArcs_NoCircles_NoChange()
{
var arc = new Arc(10, 10, 5, 0, Angle.ToRadians(90));
var line = new Line(0, 0, 10, 0);
var entities = new List<Entity> { arc, line };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Equal(2, entities.Count);
}
[Fact]
public void RemoveDuplicateArcs_MultipleArcsMatchOneCircle_RemovesAll()
{
var layer = new Layer("0");
var circle = new Circle(10, 10, 5) { Layer = layer };
var arc1 = new Arc(10, 10, 5, 0, Angle.ToRadians(90)) { Layer = layer };
var arc2 = new Arc(10, 10, 5, Angle.ToRadians(90), Angle.ToRadians(180)) { Layer = layer };
var entities = new List<Entity> { circle, arc1, arc2 };
CadImporter.RemoveDuplicateArcs(entities);
Assert.Single(entities);
Assert.Contains(circle, entities);
}
}
+46
View File
@@ -0,0 +1,46 @@
using OpenNest.Math;
using Xunit;
namespace OpenNest.Tests.Math;
public class FractionTests
{
[Theory]
[InlineData("3/8", 0.375)]
[InlineData("1 3/4", 1.75)]
[InlineData("1-3/4", 1.75)]
[InlineData("1/2", 0.5)]
public void Parse_ValidFraction_ReturnsDouble(string input, double expected)
{
var result = Fraction.Parse(input);
Assert.Equal(expected, result, 8);
}
[Theory]
[InlineData("3/8", true)]
[InlineData("abc", false)]
[InlineData("1 3/4", true)]
public void IsValid_ReturnsExpected(string input, bool expected)
{
Assert.Equal(expected, Fraction.IsValid(input));
}
[Fact]
public void TryParse_InvalidInput_ReturnsFalse()
{
var result = Fraction.TryParse("abc", out var value);
Assert.False(result);
Assert.Equal(0, value);
}
[Fact]
public void ReplaceFractionsWithDecimals_ReplacesFractionInString()
{
var result = Fraction.ReplaceFractionsWithDecimals("length is 1 3/4 inches");
Assert.Contains("1.75", result);
Assert.DoesNotContain("3/4", result);
}
}
+51
View File
@@ -0,0 +1,51 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class NgonShapeTests
{
[Fact]
public void GetDrawing_Octagon_BoundingBoxFitsWithinExpectedSize()
{
var shape = new NgonShape { Sides = 8, Width = 20 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
// Corner-to-corner is larger than flat-to-flat
Assert.True(bbox.Width >= 20 - 0.01);
Assert.True(bbox.Length >= 20 - 0.01);
// But should not be wildly larger (corner-to-corner ~ width / cos(22.5deg) ~ width * 1.0824)
Assert.True(bbox.Width < 22);
Assert.True(bbox.Length < 22);
}
[Theory]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
[InlineData(8)]
[InlineData(12)]
public void GetDrawing_HasOneLinearMovePerSide(int sides)
{
var shape = new NgonShape { Sides = sides, Width = 20 };
var drawing = shape.GetDrawing();
var moves = drawing.Program.Codes
.OfType<OpenNest.CNC.LinearMove>()
.Count();
Assert.Equal(sides, moves);
}
[Fact]
public void GetDrawing_ClampsSidesBelowThreeToTriangle()
{
var shape = new NgonShape { Sides = 2, Width = 20 };
var drawing = shape.GetDrawing();
var moves = drawing.Program.Codes
.OfType<OpenNest.CNC.LinearMove>()
.Count();
Assert.Equal(3, moves);
}
}
@@ -1,34 +0,0 @@
using OpenNest.Shapes;
namespace OpenNest.Tests.Shapes;
public class OctagonShapeTests
{
[Fact]
public void GetDrawing_BoundingBoxFitsWithinExpectedSize()
{
var shape = new OctagonShape { Width = 20 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
// Corner-to-corner is larger than flat-to-flat
Assert.True(bbox.Width >= 20 - 0.01);
Assert.True(bbox.Length >= 20 - 0.01);
// But should not be wildly larger (corner-to-corner ~ width / cos(22.5deg) ~ width * 1.0824)
Assert.True(bbox.Width < 22);
Assert.True(bbox.Length < 22);
}
[Fact]
public void GetDrawing_HasEightEdges()
{
var shape = new OctagonShape { Width = 20 };
var drawing = shape.GetDrawing();
// An octagon program should have 8 linear moves (one per edge)
var moves = drawing.Program.Codes
.OfType<OpenNest.CNC.LinearMove>()
.Count();
Assert.Equal(8, moves);
}
}
+4 -2
View File
@@ -89,8 +89,10 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
new Size(48, 24), new Size(120, 10)
};
var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories);
Console.WriteLine($"Found {dxfFiles.Length} DXF files");
var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories)
.Concat(Directory.GetFiles(dir, "*.dwg", SearchOption.AllDirectories))
.ToArray();
Console.WriteLine($"Found {dxfFiles.Length} CAD files");
var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db";
Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}");
Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations");
+1 -1
View File
@@ -1,4 +1,4 @@
using OpenNest.IO.Bom;
using OpenNest.Math;
using System;
using System.Drawing;
using System.Text;
+4 -1
View File
@@ -13,10 +13,13 @@ namespace OpenNest
private Color edgeSpacingColor;
private Color previewPartColor;
public static Color[] PartColors => Drawing.PartColors;
public string Name { get; set; } = "Unnamed";
public Color[] PartColors { get; set; } = Drawing.PartColors;
public static readonly ColorScheme Default = new ColorScheme
{
Name = "Classic",
BackgroundColor = Color.DarkGray,
LayoutOutlineColor = Color.Gray,
LayoutFillColor = Color.WhiteSmoke,
+203
View File
@@ -0,0 +1,203 @@
using OpenNest.Forms;
using OpenNest.Properties;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Windows.Forms;
namespace OpenNest
{
public static class ColorSchemeRegistry
{
private static readonly Dictionary<string, ColorScheme> builtIns =
new(StringComparer.OrdinalIgnoreCase)
{
["Classic"] = BuildClassic(),
["Pastel"] = BuildPastel(),
["Dark"] = BuildDark()
};
private static List<ColorScheme> diskCache;
public static IEnumerable<ColorScheme> AllSchemes
{
get
{
diskCache ??= LoadDiskSchemes().ToList();
return builtIns.Values.Concat(diskCache);
}
}
public static void Refresh() => diskCache = null;
public static ColorScheme Get(string name)
{
if (string.IsNullOrWhiteSpace(name))
return builtIns["Classic"];
var hit = AllSchemes.FirstOrDefault(
s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase));
return hit ?? builtIns["Classic"];
}
public static void ApplyActiveFromSettings()
{
var name = Settings.Default.ActiveColorScheme;
var scheme = Get(name);
Apply(scheme);
}
public static void Apply(ColorScheme scheme)
{
var d = ColorScheme.Default;
d.Name = scheme.Name;
d.BackgroundColor = scheme.BackgroundColor;
d.LayoutOutlineColor = scheme.LayoutOutlineColor;
d.LayoutFillColor = scheme.LayoutFillColor;
d.BoundingBoxColor = scheme.BoundingBoxColor;
d.RapidColor = scheme.RapidColor;
d.OriginColor = scheme.OriginColor;
d.EdgeSpacingColor = scheme.EdgeSpacingColor;
d.PreviewPartColor = scheme.PreviewPartColor;
d.PartColors = scheme.PartColors;
Drawing.PartColors = scheme.PartColors;
RecolorOpenNests(scheme.PartColors);
}
private static void RecolorOpenNests(Color[] palette)
{
foreach (Form f in Application.OpenForms)
{
if (f is not EditNestForm enf)
continue;
var i = 0;
foreach (var drawing in enf.Nest.Drawings)
{
if (drawing.IsCutOff)
continue;
drawing.Color = palette[i % palette.Length];
i++;
}
}
}
private static IEnumerable<ColorScheme> LoadDiskSchemes()
{
var dir = Path.Combine(AppContext.BaseDirectory, "Schemes");
if (!Directory.Exists(dir))
yield break;
foreach (var path in Directory.GetFiles(dir, "*.json"))
{
ColorScheme scheme;
try
{
scheme = ColorSchemeSerializer.Deserialize(File.ReadAllText(path));
}
catch
{
continue;
}
if (!builtIns.ContainsKey(scheme.Name))
yield return scheme;
}
}
private static ColorScheme BuildClassic() => new ColorScheme
{
Name = "Classic",
BackgroundColor = Color.DarkGray,
LayoutOutlineColor = Color.Gray,
LayoutFillColor = Color.WhiteSmoke,
BoundingBoxColor = Color.FromArgb(128, 128, 255),
RapidColor = Color.DodgerBlue,
OriginColor = Color.Gray,
EdgeSpacingColor = Color.FromArgb(180, 180, 180),
PreviewPartColor = Color.FromArgb(255, 140, 0),
PartColors = new[]
{
Color.FromArgb(205, 92, 92),
Color.FromArgb(148, 103, 189),
Color.FromArgb(75, 180, 175),
Color.FromArgb(210, 190, 75),
Color.FromArgb(190, 85, 175),
Color.FromArgb(185, 115, 85),
Color.FromArgb(120, 100, 190),
Color.FromArgb(200, 100, 140),
Color.FromArgb(80, 175, 155),
Color.FromArgb(195, 160, 85),
Color.FromArgb(175, 95, 160),
Color.FromArgb(215, 130, 130),
}
};
private static ColorScheme BuildPastel() => new ColorScheme
{
Name = "Pastel",
BackgroundColor = Color.FromArgb(70, 75, 85),
LayoutOutlineColor = Color.FromArgb(180, 180, 190),
LayoutFillColor = Color.FromArgb(245, 245, 248),
BoundingBoxColor = Color.FromArgb(128, 128, 255),
RapidColor = Color.DodgerBlue,
OriginColor = Color.FromArgb(160, 160, 160),
EdgeSpacingColor = Color.FromArgb(200, 200, 210),
PreviewPartColor = Color.FromArgb(255, 140, 0),
PartColors = new[]
{
Color.FromArgb(122, 179, 209), Color.FromArgb(254, 229, 174),
Color.FromArgb(143, 177, 229), Color.FromArgb(167, 172, 227),
Color.FromArgb(216, 249, 195), Color.FromArgb(209, 168, 216),
Color.FromArgb(222, 157, 190), Color.FromArgb(176, 255, 240),
Color.FromArgb(235, 205, 153), Color.FromArgb(177, 225, 180),
Color.FromArgb(125, 202, 241), Color.FromArgb(187, 206, 151),
Color.FromArgb(251, 175, 190), Color.FromArgb(129, 226, 227),
Color.FromArgb(255, 253, 207), Color.FromArgb(235, 205, 255),
Color.FromArgb(255, 197, 168), Color.FromArgb(116, 213, 234),
Color.FromArgb(190, 169, 122), Color.FromArgb(213, 159, 135),
Color.FromArgb(124, 184, 155), Color.FromArgb(255, 189, 214),
Color.FromArgb(146, 222, 255), Color.FromArgb(177, 173, 125),
Color.FromArgb(177, 166, 202), Color.FromArgb(197, 208, 255),
Color.FromArgb(255, 209, 243), Color.FromArgb(210, 255, 237),
Color.FromArgb(255, 237, 204), Color.FromArgb(167, 233, 255),
Color.FromArgb(182, 220, 255), Color.FromArgb(159, 177, 142),
Color.FromArgb(190, 248, 255), Color.FromArgb(187, 169, 136),
Color.FromArgb(199, 162, 168), Color.FromArgb(250, 255, 239),
Color.FromArgb(222, 233, 255), Color.FromArgb(255, 234, 225),
Color.FromArgb(240, 249, 255), Color.FromArgb(152, 176, 176),
}
};
private static ColorScheme BuildDark() => new ColorScheme
{
Name = "Dark",
BackgroundColor = Color.FromArgb(30, 30, 34),
LayoutOutlineColor = Color.FromArgb(90, 90, 95),
LayoutFillColor = Color.FromArgb(50, 50, 55),
BoundingBoxColor = Color.FromArgb(100, 160, 220),
RapidColor = Color.FromArgb(255, 200, 50),
OriginColor = Color.FromArgb(120, 120, 130),
EdgeSpacingColor = Color.FromArgb(90, 90, 100),
PreviewPartColor = Color.FromArgb(255, 170, 60),
PartColors = new[]
{
Color.FromArgb(255, 85, 85), // Neon Red
Color.FromArgb(80, 220, 255), // Electric Cyan
Color.FromArgb(255, 200, 50), // Amber
Color.FromArgb(130, 255, 130), // Lime Green
Color.FromArgb(255, 130, 220), // Hot Pink
Color.FromArgb(255, 165, 70), // Tangerine
Color.FromArgb(100, 180, 255), // Sky Blue
Color.FromArgb(200, 160, 255), // Lavender
Color.FromArgb(50, 230, 180), // Mint
Color.FromArgb(255, 255, 100), // Lemon
Color.FromArgb(255, 120, 120), // Salmon
Color.FromArgb(140, 230, 255), // Ice Blue
}
};
}
}
+84
View File
@@ -0,0 +1,84 @@
using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Text.Json;
namespace OpenNest
{
public static class ColorSchemeSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static string Serialize(ColorScheme scheme)
{
var dto = new ColorSchemeDto
{
Name = scheme.Name,
BackgroundColor = ToHex(scheme.BackgroundColor),
LayoutOutlineColor = ToHex(scheme.LayoutOutlineColor),
LayoutFillColor = ToHex(scheme.LayoutFillColor),
BoundingBoxColor = ToHex(scheme.BoundingBoxColor),
RapidColor = ToHex(scheme.RapidColor),
OriginColor = ToHex(scheme.OriginColor),
EdgeSpacingColor = ToHex(scheme.EdgeSpacingColor),
PreviewPartColor = ToHex(scheme.PreviewPartColor),
PartColors = scheme.PartColors.Select(ToHex).ToArray()
};
return JsonSerializer.Serialize(dto, JsonOptions);
}
public static ColorScheme Deserialize(string json)
{
var dto = JsonSerializer.Deserialize<ColorSchemeDto>(json, JsonOptions)
?? throw new JsonException("ColorScheme JSON was null");
return new ColorScheme
{
Name = dto.Name ?? "Unnamed",
BackgroundColor = FromHex(dto.BackgroundColor),
LayoutOutlineColor = FromHex(dto.LayoutOutlineColor),
LayoutFillColor = FromHex(dto.LayoutFillColor),
BoundingBoxColor = FromHex(dto.BoundingBoxColor),
RapidColor = FromHex(dto.RapidColor),
OriginColor = FromHex(dto.OriginColor),
EdgeSpacingColor = FromHex(dto.EdgeSpacingColor),
PreviewPartColor = FromHex(dto.PreviewPartColor),
PartColors = (dto.PartColors ?? new string[0]).Select(FromHex).ToArray()
};
}
private static string ToHex(Color c) =>
"#" + c.R.ToString("X2") + c.G.ToString("X2") + c.B.ToString("X2");
private static Color FromHex(string hex)
{
if (string.IsNullOrWhiteSpace(hex))
return Color.Black;
var h = hex.TrimStart('#');
if (h.Length < 6)
return Color.Black;
var r = byte.Parse(h.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
var g = byte.Parse(h.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
var b = byte.Parse(h.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
return Color.FromArgb(r, g, b);
}
private class ColorSchemeDto
{
public string Name { get; set; }
public string BackgroundColor { get; set; }
public string LayoutOutlineColor { get; set; }
public string LayoutFillColor { get; set; }
public string BoundingBoxColor { get; set; }
public string RapidColor { get; set; }
public string OriginColor { get; set; }
public string EdgeSpacingColor { get; set; }
public string PreviewPartColor { get; set; }
public string[] PartColors { get; set; }
}
}
}
+16
View File
@@ -0,0 +1,16 @@
using System.Drawing;
using OpenNest.Geometry;
namespace OpenNest.Controls
{
public class CadText
{
public Vector Position { get; set; }
public string Value { get; set; }
public double Height { get; set; }
public double Rotation { get; set; }
public string LayerName { get; set; }
public StringAlignment HAlign { get; set; }
public StringAlignment VAlign { get; set; }
}
}
+57 -18
View File
@@ -24,6 +24,8 @@ namespace OpenNest.Controls
private readonly CheckBox chkTabsEnabled;
private readonly NumericUpDown nudTabWidth;
private readonly RadioButton rbTabAll;
private readonly RadioButton rbAutoTab;
private readonly NumericUpDown nudAutoTabMin;
private readonly NumericUpDown nudAutoTabMax;
private readonly NumericUpDown nudPierceClearance;
@@ -112,7 +114,7 @@ namespace OpenNest.Controls
{
HeaderText = "Tabs",
Dock = DockStyle.Top,
ExpandedHeight = 120,
ExpandedHeight = 160,
IsExpanded = false
};
@@ -122,44 +124,78 @@ namespace OpenNest.Controls
Location = new Point(12, 4),
AutoSize = true
};
chkTabsEnabled.CheckedChanged += (s, e) =>
{
nudTabWidth.Enabled = chkTabsEnabled.Checked;
OnParametersChanged();
};
tabsPanel.ContentPanel.Controls.Add(chkTabsEnabled);
tabsPanel.ContentPanel.Controls.Add(new Label
{
Text = "Width:",
Text = "Tab Size:",
Location = new Point(160, 6),
AutoSize = true
});
nudTabWidth = CreateNumeric(215, 3, 0.25, 0.0625);
nudTabWidth = CreateNumeric(225, 3, 0.25, 0.0625);
nudTabWidth.Enabled = false;
tabsPanel.ContentPanel.Controls.Add(nudTabWidth);
rbTabAll = new RadioButton
{
Text = "Tab all parts",
Location = new Point(28, 28),
AutoSize = true,
Enabled = false,
Checked = true
};
tabsPanel.ContentPanel.Controls.Add(rbTabAll);
rbAutoTab = new RadioButton
{
Text = "Auto-tab when smallest part dimension is between:",
Location = new Point(28, 50),
AutoSize = true,
Enabled = false
};
tabsPanel.ContentPanel.Controls.Add(rbAutoTab);
tabsPanel.ContentPanel.Controls.Add(new Label
{
Text = "Auto-Tab Min Size:",
Location = new Point(12, 32),
Text = "Min:",
Location = new Point(44, 76),
AutoSize = true
});
nudAutoTabMin = CreateNumeric(140, 29, 0, 0.0625);
nudAutoTabMin = CreateNumeric(77, 73, 0, 0.0625);
nudAutoTabMin.Enabled = false;
tabsPanel.ContentPanel.Controls.Add(nudAutoTabMin);
tabsPanel.ContentPanel.Controls.Add(new Label
{
Text = "Auto-Tab Max Size:",
Location = new Point(12, 58),
Text = "Max:",
Location = new Point(210, 76),
AutoSize = true
});
nudAutoTabMax = CreateNumeric(140, 55, 0, 0.0625);
nudAutoTabMax = CreateNumeric(245, 73, 0, 0.0625);
nudAutoTabMax.Enabled = false;
tabsPanel.ContentPanel.Controls.Add(nudAutoTabMax);
chkTabsEnabled.CheckedChanged += (s, e) =>
{
var enabled = chkTabsEnabled.Checked;
nudTabWidth.Enabled = enabled;
rbTabAll.Enabled = enabled;
rbAutoTab.Enabled = enabled;
nudAutoTabMin.Enabled = enabled && rbAutoTab.Checked;
nudAutoTabMax.Enabled = enabled && rbAutoTab.Checked;
OnParametersChanged();
};
rbTabAll.CheckedChanged += (s, e) =>
{
nudAutoTabMin.Enabled = chkTabsEnabled.Checked && rbAutoTab.Checked;
nudAutoTabMax.Enabled = chkTabsEnabled.Checked && rbAutoTab.Checked;
OnParametersChanged();
};
// Pierce section
var piercePanel = new CollapsiblePanel
{
@@ -246,13 +282,13 @@ namespace OpenNest.Controls
InternalLeadOut = BuildLeadOut(cboInternalLeadOut, pnlInternalLeadOut),
ArcCircleLeadIn = BuildLeadIn(cboArcCircleLeadIn, pnlArcCircleLeadIn),
ArcCircleLeadOut = BuildLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut),
TabsEnabled = chkTabsEnabled.Checked,
TabsEnabled = chkTabsEnabled.Checked && rbTabAll.Checked,
TabConfig = new NormalTab { Size = (double)nudTabWidth.Value },
PierceClearance = (double)nudPierceClearance.Value,
RoundLeadInAngles = chkRoundLeadInAngles.Checked,
LeadInAngleIncrement = (double)nudLeadInAngleIncrement.Value,
AutoTabMinSize = (double)nudAutoTabMin.Value,
AutoTabMaxSize = (double)nudAutoTabMax.Value
AutoTabMinSize = chkTabsEnabled.Checked && rbAutoTab.Checked ? (double)nudAutoTabMin.Value : 0,
AutoTabMaxSize = chkTabsEnabled.Checked && rbAutoTab.Checked ? (double)nudAutoTabMax.Value : 0
};
}
@@ -267,7 +303,10 @@ namespace OpenNest.Controls
LoadLeadIn(cboArcCircleLeadIn, pnlArcCircleLeadIn, p.ArcCircleLeadIn);
LoadLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut, p.ArcCircleLeadOut);
chkTabsEnabled.Checked = p.TabsEnabled;
var hasAutoTab = p.AutoTabMinSize > 0 || p.AutoTabMaxSize > 0;
chkTabsEnabled.Checked = p.TabsEnabled || hasAutoTab;
rbAutoTab.Checked = hasAutoTab;
rbTabAll.Checked = !hasAutoTab;
if (p.TabConfig != null)
nudTabWidth.Value = (decimal)p.TabConfig.Size;
nudPierceClearance.Value = (decimal)p.PierceClearance;
+33
View File
@@ -29,12 +29,14 @@ namespace OpenNest.Controls
public List<Entity> SimplifierToleranceRight { get; set; }
public List<Entity> OriginalEntities { get; set; }
public bool ShowEntityLabels { get; set; }
public List<CadText> Texts { get; set; } = new List<CadText>();
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
private readonly Font labelFont = new Font("Segoe UI", 7f);
private readonly SolidBrush labelBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
private readonly SolidBrush labelBackBrush = new SolidBrush(Color.FromArgb(33, 40, 48));
private readonly SolidBrush textBrush = new SolidBrush(Color.FromArgb(180, 200, 200, 200));
public event EventHandler<Line> LinePicked;
public event EventHandler PickCancelled;
@@ -116,6 +118,8 @@ namespace OpenNest.Controls
DrawEntity(e.Graphics, entity, pen);
}
DrawTexts(e.Graphics);
if (ShowEntityLabels)
DrawEntityLabels(e.Graphics);
@@ -408,6 +412,7 @@ namespace OpenNest.Controls
labelFont.Dispose();
labelBrush.Dispose();
labelBackBrush.Dispose();
textBrush.Dispose();
}
base.Dispose(disposing);
}
@@ -474,6 +479,34 @@ namespace OpenNest.Controls
diameter);
}
private void DrawTexts(Graphics g)
{
if (Texts == null || Texts.Count == 0)
return;
using var sf = new StringFormat();
foreach (var text in Texts)
{
var pos = PointWorldToGraph(text.Position);
var fontSize = LengthWorldToGui(text.Height);
if (fontSize < 2f) continue;
var state = g.Save();
g.TranslateTransform(pos.X, pos.Y);
if (text.Rotation != 0)
g.RotateTransform((float)OpenNest.Math.Angle.ToDegrees(text.Rotation));
sf.Alignment = text.HAlign;
sf.LineAlignment = text.VAlign;
using var font = new Font("Segoe UI", fontSize, GraphicsUnit.Pixel);
g.DrawString(text.Value, font, textBrush, 0, 0, sf);
g.Restore(state);
}
}
private void DrawPoint(Graphics g, Vector pt, Pen pen)
{
var pt1 = PointWorldToGraph(pt);
+1
View File
@@ -22,6 +22,7 @@ namespace OpenNest.Controls
public HashSet<Guid> SuppressedEntityIds { get; set; }
public Box Bounds { get; set; }
public int EntityCount { get; set; }
public List<CadText> Texts { get; set; } = new();
}
public class FileListControl : Control
+5 -70
View File
@@ -385,85 +385,20 @@ namespace OpenNest.Controls
private void DrawRapids(Graphics g)
{
var pen = view.ColorScheme.RapidPen;
var pos = new Vector(0, 0);
for (var i = 0; i < view.Plate.Parts.Count; ++i)
{
var part = view.Plate.Parts[i];
var pgm = part.Program;
var segments = RapidEnumerator.Enumerate(part.Program, part.Location, pos);
var piercePoint = GetFirstPiercePoint(pgm, part.Location);
DrawLine(g, pos, piercePoint, view.ColorScheme.RapidPen);
pos = piercePoint;
DrawRapids(g, pgm, part.Location, ref pos, skipFirstRapid: true);
}
}
private static Vector GetFirstPiercePoint(Program pgm, Vector partLocation)
{
for (var i = 0; i < pgm.Length; i++)
{
if (pgm[i] is SubProgramCall call && call.Program != null)
return GetFirstPiercePoint(call.Program, partLocation + call.Offset);
if (pgm[i] is Motion motion)
foreach (var seg in segments)
{
return motion.EndPoint + partLocation;
DrawLine(g, seg.From, seg.To, pen);
pos = seg.To;
}
}
return partLocation;
}
private void DrawRapids(Graphics g, Program pgm, Vector basePos, ref Vector pos, bool skipFirstRapid = false)
{
var firstRapidSkipped = false;
for (var i = 0; i < pgm.Length; ++i)
{
var code = pgm[i];
if (code is SubProgramCall { Program: { } program } call)
{
// A SubProgramCall is a coordinate-frame shift, not a physical
// rapid to the hole center. The Cincinnati post emits it as a
// G52 bracket, so the physical rapid is the sub-program's first
// motion, which goes straight from here to the lead-in pierce.
// Look ahead for that pierce point and draw the direct rapid,
// then recurse with skipFirstRapid so the sub doesn't also draw
// its first rapid on top. See docs/cincinnati-post-output.md.
var holeBase = basePos + call.Offset;
var firstPierce = GetFirstPiercePoint(program, holeBase);
if (ShouldDrawRapid(skipFirstRapid, ref firstRapidSkipped))
DrawLine(g, pos, firstPierce, view.ColorScheme.RapidPen);
var subPos = holeBase;
DrawRapids(g, program, holeBase, ref subPos, skipFirstRapid: true);
pos = subPos;
}
else if (code is Motion motion)
{
var endpt = pgm.Mode == Mode.Incremental
? motion.EndPoint + pos
: motion.EndPoint;
if (code.Type == CodeType.RapidMove && ShouldDrawRapid(skipFirstRapid, ref firstRapidSkipped))
DrawLine(g, pos, endpt, view.ColorScheme.RapidPen);
pos = endpt;
}
}
}
private static bool ShouldDrawRapid(bool skipFirstRapid, ref bool firstRapidSkipped)
{
if (skipFirstRapid && !firstRapidSkipped)
{
firstRapidSkipped = true;
return false;
}
return true;
}
private void DrawAllPiercePoints(Graphics g)
+3
View File
@@ -464,6 +464,9 @@ namespace OpenNest.Controls
protected override void OnPaint(PaintEventArgs e)
{
if (BackColor != ColorScheme.BackgroundColor)
BackColor = ColorScheme.BackgroundColor;
e.Graphics.SmoothingMode = SmoothingMode.HighSpeed;
if (DrawOrigin)
+2 -1
View File
@@ -165,7 +165,8 @@ namespace OpenNest.Forms
else
{
var lookupName = item.FileName;
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase))
lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (matchedPaths.TryGetValue(lookupName, out var dxfPath))
+101 -2
View File
@@ -92,7 +92,8 @@ namespace OpenNest.Forms
Customer = string.Empty,
Bends = result.Bends,
Bounds = result.Bounds,
EntityCount = result.Entities.Count
EntityCount = result.Entities.Count,
Texts = ExtractTexts(result.Document),
};
if (InvokeRequired)
@@ -152,6 +153,7 @@ namespace OpenNest.Forms
entityView1.Entities.Clear();
entityView1.Entities.AddRange(item.Entities);
entityView1.Bends = item.Bends ?? new List<Bend>();
entityView1.Texts = item.Texts ?? new List<CadText>();
item.Entities.ForEach(e => e.IsVisible = true);
if (item.Entities.Any(e => e.Layer != null))
@@ -473,7 +475,8 @@ namespace OpenNest.Forms
{
var files = (string[])e.Data.GetData(DataFormats.FileDrop);
var dxfFiles = files.Where(f =>
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToArray();
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToArray();
if (dxfFiles.Length > 0)
AddFiles(dxfFiles);
}
@@ -803,6 +806,102 @@ namespace OpenNest.Forms
#endregion
private static List<CadText> ExtractTexts(ACadSharp.CadDocument doc)
{
var texts = new List<CadText>();
if (doc == null) return texts;
foreach (var entity in doc.Entities)
{
switch (entity)
{
case ACadSharp.Entities.MText mtext:
var (mh, mv) = MapAttachmentPoint(mtext.AttachmentPoint);
texts.Add(new CadText
{
Position = new Vector(mtext.InsertPoint.X, mtext.InsertPoint.Y),
Value = ReplaceControlCodes(StripMTextFormatting(mtext.Value)),
Height = mtext.Height,
Rotation = mtext.Rotation,
LayerName = mtext.Layer?.Name,
HAlign = mh,
VAlign = mv,
});
break;
case ACadSharp.Entities.TextEntity text:
var useAlignment = text.HorizontalAlignment != 0
|| text.VerticalAlignment != 0;
var pt = useAlignment ? text.AlignmentPoint : text.InsertPoint;
var ha = text.HorizontalAlignment switch
{
ACadSharp.Entities.TextHorizontalAlignment.Center => System.Drawing.StringAlignment.Center,
ACadSharp.Entities.TextHorizontalAlignment.Right => System.Drawing.StringAlignment.Far,
_ => System.Drawing.StringAlignment.Near,
};
texts.Add(new CadText
{
Position = new Vector(pt.X, pt.Y),
Value = ReplaceControlCodes(text.Value),
Height = text.Height,
Rotation = text.Rotation,
LayerName = text.Layer?.Name,
HAlign = ha,
});
break;
}
}
return texts;
}
private static (System.Drawing.StringAlignment h, System.Drawing.StringAlignment v) MapAttachmentPoint(
ACadSharp.Entities.AttachmentPointType apt)
{
var h = apt switch
{
ACadSharp.Entities.AttachmentPointType.TopCenter
or ACadSharp.Entities.AttachmentPointType.MiddleCenter
or ACadSharp.Entities.AttachmentPointType.BottomCenter => System.Drawing.StringAlignment.Center,
ACadSharp.Entities.AttachmentPointType.TopRight
or ACadSharp.Entities.AttachmentPointType.MiddleRight
or ACadSharp.Entities.AttachmentPointType.BottomRight => System.Drawing.StringAlignment.Far,
_ => System.Drawing.StringAlignment.Near,
};
var v = apt switch
{
ACadSharp.Entities.AttachmentPointType.MiddleLeft
or ACadSharp.Entities.AttachmentPointType.MiddleCenter
or ACadSharp.Entities.AttachmentPointType.MiddleRight => System.Drawing.StringAlignment.Center,
ACadSharp.Entities.AttachmentPointType.BottomLeft
or ACadSharp.Entities.AttachmentPointType.BottomCenter
or ACadSharp.Entities.AttachmentPointType.BottomRight => System.Drawing.StringAlignment.Far,
_ => System.Drawing.StringAlignment.Near,
};
return (h, v);
}
private static string StripMTextFormatting(string text)
{
if (string.IsNullOrEmpty(text)) return text;
var result = System.Text.RegularExpressions.Regex.Replace(text, @"\\[A-Za-z][^;]*;", "");
result = result.Replace("{", "").Replace("}", "");
return result.Trim();
}
private static string ReplaceControlCodes(string text)
{
if (string.IsNullOrEmpty(text)) return text;
return text
.Replace("%%p", "±")
.Replace("%%P", "±")
.Replace("%%d", "°")
.Replace("%%D", "°")
.Replace("%%c", "⌀")
.Replace("%%C", "⌀")
.Replace("%%%", "%");
}
private void filterPanel_Paint(object sender, PaintEventArgs e)
{
+12 -21
View File
@@ -47,11 +47,9 @@
drawingListBox1 = new OpenNest.Controls.DrawingListBox();
toolStrip2 = new System.Windows.Forms.ToolStrip();
toolStripButton2 = new System.Windows.Forms.ToolStripButton();
toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator();
shapeLibraryButton = new System.Windows.Forms.ToolStripButton();
editDrawingsButton = new System.Windows.Forms.ToolStripButton();
toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator();
toolStripButton3 = new System.Windows.Forms.ToolStripButton();
toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator();
hideNestedButton = new System.Windows.Forms.ToolStripButton();
((System.ComponentModel.ISupportInitialize)splitContainer).BeginInit();
splitContainer.Panel1.SuspendLayout();
@@ -81,8 +79,8 @@
//
// tabControl1
//
tabControl1.Controls.Add(tabPage1);
tabControl1.Controls.Add(tabPage2);
tabControl1.Controls.Add(tabPage1);
tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
tabControl1.ItemSize = new System.Drawing.Size(100, 22);
tabControl1.Location = new System.Drawing.Point(0, 0);
@@ -219,7 +217,7 @@
//
toolStrip2.GripStyle = System.Windows.Forms.ToolStripGripStyle.Hidden;
toolStrip2.ImageScalingSize = new System.Drawing.Size(20, 20);
toolStrip2.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { toolStripButton2, toolStripSeparator4, editDrawingsButton, toolStripSeparator1, toolStripButton3, toolStripSeparator2, hideNestedButton });
toolStrip2.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { toolStripButton2, shapeLibraryButton, editDrawingsButton, toolStripButton3, hideNestedButton });
toolStrip2.Location = new System.Drawing.Point(4, 3);
toolStrip2.Name = "toolStrip2";
toolStrip2.Size = new System.Drawing.Size(265, 27);
@@ -238,10 +236,15 @@
toolStripButton2.Text = "Import Drawings";
toolStripButton2.Click += ImportDrawings_Click;
//
// toolStripSeparator4
// shapeLibraryButton
//
toolStripSeparator4.Name = "toolStripSeparator4";
toolStripSeparator4.Size = new System.Drawing.Size(6, 27);
shapeLibraryButton.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image;
shapeLibraryButton.Image = Properties.Resources.shapes;
shapeLibraryButton.Name = "shapeLibraryButton";
shapeLibraryButton.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
shapeLibraryButton.Size = new System.Drawing.Size(34, 24);
shapeLibraryButton.Text = "Shape Library";
shapeLibraryButton.Click += ShapeLibrary_Click;
//
// editDrawingsButton
//
@@ -253,11 +256,6 @@
editDrawingsButton.Text = "Edit Drawings in Converter";
editDrawingsButton.Click += EditDrawingsInConverter_Click;
//
// toolStripSeparator1
//
toolStripSeparator1.Name = "toolStripSeparator1";
toolStripSeparator1.Size = new System.Drawing.Size(6, 27);
//
// toolStripButton3
//
toolStripButton3.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image;
@@ -269,11 +267,6 @@
toolStripButton3.Text = "Cleanup unused Drawings";
toolStripButton3.Click += CleanUnusedDrawings_Click;
//
// toolStripSeparator2
//
toolStripSeparator2.Name = "toolStripSeparator2";
toolStripSeparator2.Size = new System.Drawing.Size(6, 27);
//
// hideNestedButton
//
hideNestedButton.CheckOnClick = true;
@@ -329,11 +322,9 @@
private System.Windows.Forms.ColumnHeader utilColumn;
private System.Windows.Forms.ToolStrip toolStrip2;
private System.Windows.Forms.ToolStripButton toolStripButton2;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator4;
private System.Windows.Forms.ToolStripButton shapeLibraryButton;
private System.Windows.Forms.ToolStripButton editDrawingsButton;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator1;
private System.Windows.Forms.ToolStripButton toolStripButton3;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator2;
private System.Windows.Forms.ToolStripButton hideNestedButton;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator3;
private System.Windows.Forms.ToolStripButton toolStripLabel1;
+13 -2
View File
@@ -329,7 +329,7 @@ namespace OpenNest.Forms
{
var dlg = new OpenFileDialog();
dlg.Multiselect = true;
dlg.Filter = "DXF Files (*.dxf) | *.dxf";
dlg.Filter = "CAD Files (*.dxf;*.dwg)|*.dxf;*.dwg|DXF Files (*.dxf)|*.dxf|DWG Files (*.dwg)|*.dwg";
if (dlg.ShowDialog() != DialogResult.OK)
return;
@@ -346,7 +346,6 @@ namespace OpenNest.Forms
drawings.ForEach(d => Nest.Drawings.Add(d));
UpdateDrawingList();
tabControl1.SelectedIndex = 1;
}
public bool Export()
@@ -875,6 +874,18 @@ namespace OpenNest.Forms
Import();
}
private void ShapeLibrary_Click(object sender, EventArgs e)
{
var form = new ShapeLibraryForm(Nest.Drawings.Select(d => d.Name));
form.ShowDialog();
var drawings = form.GetDrawings();
if (drawings.Count == 0) return;
drawings.ForEach(d => Nest.Drawings.Add(d));
UpdateDrawingList();
}
private void EditDrawingsInConverter_Click(object sender, EventArgs e)
{
if (Nest.Drawings.Count == 0)
+4 -3
View File
@@ -63,7 +63,7 @@
this.textBox2 = new System.Windows.Forms.TextBox();
this.label5 = new System.Windows.Forms.Label();
this.labelMaterial = new System.Windows.Forms.Label();
this.materialBox = new System.Windows.Forms.TextBox();
this.materialBox = new System.Windows.Forms.ComboBox();
this.tabPage2 = new System.Windows.Forms.TabPage();
this.tabPage3 = new System.Windows.Forms.TabPage();
this.notesBox = new System.Windows.Forms.TextBox();
@@ -516,9 +516,10 @@
// materialBox
//
this.materialBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
this.materialBox.FormattingEnabled = true;
this.materialBox.Location = new System.Drawing.Point(135, 159);
this.materialBox.Name = "materialBox";
this.materialBox.Size = new System.Drawing.Size(224, 22);
this.materialBox.Size = new System.Drawing.Size(224, 24);
this.materialBox.TabIndex = 11;
//
// label3
@@ -729,6 +730,6 @@
private System.Windows.Forms.RadioButton radioButton2;
private System.Windows.Forms.Label label5;
private System.Windows.Forms.Label labelMaterial;
private System.Windows.Forms.TextBox materialBox;
private System.Windows.Forms.ComboBox materialBox;
}
}
+3
View File
@@ -15,6 +15,9 @@ namespace OpenNest.Forms
{
InitializeComponent();
foreach (var name in PostProcessorMaterials.Names)
materialBox.Items.Add(name);
timer = new Timer
{
SynchronizingObject = this,
+8 -1
View File
@@ -71,6 +71,7 @@ namespace OpenNest.Forms
NestEngineRegistry.LoadPlugins(enginesDir);
OptionsForm.ApplyDisabledStrategies();
ColorSchemeRegistry.ApplyActiveFromSettings();
foreach (var engine in NestEngineRegistry.AvailableEngines)
engineComboBox.Items.Add(engine.Name);
@@ -351,6 +352,9 @@ namespace OpenNest.Forms
postProcessorMenuItem.Tag = postProcessor;
postProcessorMenuItem.Click += PostProcessor_Click;
mnuNestPost.DropDownItems.Add(postProcessorMenuItem);
if (postProcessor is IMaterialProvidingPostProcessor materialProvider)
PostProcessorMaterials.AddFrom(materialProvider);
}
}
}
@@ -833,7 +837,7 @@ namespace OpenNest.Forms
{
if (activeForm == null) return;
var form = new ShapeLibraryForm();
var form = new ShapeLibraryForm(activeForm.Nest.Drawings.Select(d => d.Name));
form.ShowDialog();
var drawings = form.GetDrawings();
@@ -1157,6 +1161,9 @@ namespace OpenNest.Forms
if (postProcessor == null)
return;
if (postProcessor is IPostProcessorNestAware nestAware)
nestAware.PrepareForNest(activeForm.Nest);
if (postProcessor is IConfigurablePostProcessor configurable)
{
using var configForm = new PostProcessorConfigForm(configurable);
+31 -5
View File
@@ -42,6 +42,8 @@
this.bottomPanel1 = new OpenNest.Controls.BottomPanel();
this.strategyGrid = new System.Windows.Forms.DataGridView();
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
this.colorSchemeLabel = new System.Windows.Forms.Label();
this.colorSchemeCombo = new System.Windows.Forms.ComboBox();
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
this.tableLayoutPanel1.SuspendLayout();
this.bottomPanel1.SuspendLayout();
@@ -95,15 +97,18 @@
this.tableLayoutPanel1.Controls.Add(this.label1, 0, 1);
this.tableLayoutPanel1.Controls.Add(this.textBox1, 1, 0);
this.tableLayoutPanel1.Controls.Add(this.label3, 0, 0);
this.tableLayoutPanel1.Controls.Add(this.checkBox1, 0, 2);
this.tableLayoutPanel1.Controls.Add(this.colorSchemeLabel, 0, 2);
this.tableLayoutPanel1.Controls.Add(this.colorSchemeCombo, 1, 2);
this.tableLayoutPanel1.Controls.Add(this.checkBox1, 0, 3);
this.tableLayoutPanel1.Controls.Add(this.numericUpDown1, 1, 1);
this.tableLayoutPanel1.Controls.Add(this.button1, 3, 0);
this.tableLayoutPanel1.Location = new System.Drawing.Point(12, 12);
this.tableLayoutPanel1.Name = "tableLayoutPanel1";
this.tableLayoutPanel1.RowCount = 3;
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.34F));
this.tableLayoutPanel1.RowCount = 4;
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
this.tableLayoutPanel1.Size = new System.Drawing.Size(684, 160);
this.tableLayoutPanel1.TabIndex = 0;
//
@@ -198,6 +203,25 @@
this.strategyGroupBox.TabStop = false;
this.strategyGroupBox.Text = "Fill Strategies";
//
// colorSchemeLabel
//
this.colorSchemeLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
this.colorSchemeLabel.AutoSize = true;
this.colorSchemeLabel.Location = new System.Drawing.Point(3, 92);
this.colorSchemeLabel.Name = "colorSchemeLabel";
this.colorSchemeLabel.Size = new System.Drawing.Size(145, 16);
this.colorSchemeLabel.TabIndex = 10;
this.colorSchemeLabel.Text = "Color scheme:";
//
// colorSchemeCombo
//
this.colorSchemeCombo.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
this.colorSchemeCombo.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
this.colorSchemeCombo.Location = new System.Drawing.Point(154, 89);
this.colorSchemeCombo.Name = "colorSchemeCombo";
this.colorSchemeCombo.Size = new System.Drawing.Size(130, 24);
this.colorSchemeCombo.TabIndex = 11;
//
// OptionsForm
//
this.AcceptButton = this.saveButton;
@@ -239,5 +263,7 @@
private System.Windows.Forms.Button button1;
private System.Windows.Forms.DataGridView strategyGrid;
private System.Windows.Forms.GroupBox strategyGroupBox;
private System.Windows.Forms.Label colorSchemeLabel;
private System.Windows.Forms.ComboBox colorSchemeCombo;
}
}
+12
View File
@@ -68,6 +68,13 @@ namespace OpenNest.Forms
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
colorSchemeCombo.Items.Clear();
foreach (var scheme in ColorSchemeRegistry.AllSchemes)
colorSchemeCombo.Items.Add(scheme.Name);
var active = Settings.Default.ActiveColorScheme;
var idx = colorSchemeCombo.Items.IndexOf(active);
colorSchemeCombo.SelectedIndex = idx >= 0 ? idx : 0;
var disabledNames = ParseDisabledStrategies(Settings.Default.DisabledStrategies);
foreach (DataGridViewRow row in strategyGrid.Rows)
row.Cells["Enabled"].Value = !disabledNames.Contains((string)row.Cells["Name"].Value);
@@ -78,6 +85,7 @@ namespace OpenNest.Forms
Settings.Default.NestTemplatePath = textBox1.Text;
Settings.Default.CreateNewNestOnOpen = checkBox1.Checked;
Settings.Default.AutoSizePlateFactor = (double)numericUpDown1.Value;
Settings.Default.ActiveColorScheme = colorSchemeCombo.SelectedItem as string ?? "Classic";
var disabledNames = new List<string>();
foreach (DataGridViewRow row in strategyGrid.Rows)
@@ -89,6 +97,10 @@ namespace OpenNest.Forms
Settings.Default.Save();
ApplyDisabledStrategies();
ColorSchemeRegistry.ApplyActiveFromSettings();
foreach (Form f in Application.OpenForms)
f.Invalidate(invalidateChildren: true);
}
/// <summary>
+22 -1
View File
@@ -21,12 +21,17 @@ namespace OpenNest.Forms
private readonly List<Drawing> addedDrawings = new List<Drawing>();
private readonly List<ShapeEntry> shapeEntries = new List<ShapeEntry>();
private readonly List<ParameterBinding> parameterBindings = new List<ParameterBinding>();
private readonly HashSet<string> existingNames;
private ShapeEntry selectedEntry;
private bool suppressPreview;
public ShapeLibraryForm()
public ShapeLibraryForm(IEnumerable<string> existingDrawingNames = null)
{
existingNames = existingDrawingNames != null
? new HashSet<string>(existingDrawingNames, StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
InitializeComponent();
DiscoverShapes();
PopulateShapeList();
@@ -259,6 +264,7 @@ namespace OpenNest.Forms
if (shape == null) return;
var drawing = shape.GetDrawing();
nameTextBox.Text = shape.GenerateName();
previewBox.ShowDrawing(drawing);
if (drawing?.Program != null)
@@ -405,10 +411,12 @@ namespace OpenNest.Forms
if (shape == null) return;
var drawing = shape.GetDrawing();
drawing.Name = GetUniqueName(drawing.Name);
drawing.Color = Drawing.GetNextColor();
drawing.Quantity.Required = (int)quantityUpDown.Value;
addedDrawings.Add(drawing);
existingNames.Add(drawing.Name);
DialogResult = DialogResult.OK;
addButton.Text = $"Added ({addedDrawings.Count})";
@@ -423,6 +431,19 @@ namespace OpenNest.Forms
}
}
private string GetUniqueName(string baseName)
{
if (!existingNames.Contains(baseName))
return baseName;
for (var i = 2; ; i++)
{
var candidate = $"{baseName} ({i})";
if (!existingNames.Contains(candidate))
return candidate;
}
}
private static string FriendlyName(string name)
{
if (name.EndsWith("Shape"))
+25 -5
View File
@@ -138,9 +138,20 @@ namespace OpenNest
break;
case CodeType.RapidMove:
cutPath.StartFigure();
leadPath.StartFigure();
AddLine(cutPath, (RapidMove)code, mode, ref curpos);
{
var rapid = (RapidMove)code;
var endpt = rapid.EndPoint;
if (mode == Mode.Incremental)
endpt += curpos;
var dx = endpt.X - curpos.X;
var dy = endpt.Y - curpos.Y;
if (dx * dx + dy * dy > 0.001 * 0.001)
{
cutPath.StartFigure();
leadPath.StartFigure();
}
curpos = endpt;
}
break;
case CodeType.SubProgramCall:
@@ -300,8 +311,17 @@ namespace OpenNest
break;
case CodeType.RapidMove:
Flush();
AddLine(path, (RapidMove)code, mode, ref curpos);
{
var rapid = (RapidMove)code;
var endpt = rapid.EndPoint;
if (mode == Mode.Incremental)
endpt += curpos;
var dx = endpt.X - curpos.X;
var dy = endpt.Y - curpos.Y;
if (dx * dx + dy * dy > 0.001 * 0.001)
Flush();
curpos = endpt;
}
break;
case CodeType.SubProgramCall:
+5
View File
@@ -15,6 +15,11 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<None Update="Schemes\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
+30
View File
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest
{
public static class PostProcessorMaterials
{
private static readonly List<string> materials = new();
public static IReadOnlyList<string> Names => materials;
public static void AddFrom(IMaterialProvidingPostProcessor provider)
{
if (provider == null)
return;
foreach (var name in provider.GetMaterialNames())
{
if (!string.IsNullOrWhiteSpace(name)
&& !materials.Contains(name, StringComparer.OrdinalIgnoreCase))
{
materials.Add(name);
}
}
materials.Sort(StringComparer.OrdinalIgnoreCase);
}
}
}
+10
View File
@@ -250,6 +250,16 @@ namespace OpenNest.Properties {
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap shapes {
get {
object obj = ResourceManager.GetObject("shapes", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
+3
View File
@@ -187,4 +187,7 @@
<data name="delete" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\delete.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="shapes" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\shapes.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
</root>
+12
View File
@@ -226,5 +226,17 @@ namespace OpenNest.Properties {
this["CuttingParametersJson"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("Classic")]
public string ActiveColorScheme {
get {
return ((string)(this["ActiveColorScheme"]));
}
set {
this["ActiveColorScheme"] = value;
}
}
}
}
+3
View File
@@ -53,5 +53,8 @@
<Setting Name="CuttingParametersJson" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
<Setting Name="ActiveColorScheme" Type="System.String" Scope="User">
<Value Profile="(Default)">Classic</Value>
</Setting>
</Settings>
</SettingsFile>
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File