Compare commits

...

29 Commits

Author SHA1 Message Date
aj 2db8c49838 feat: add etch mark entities from bend lines to CNC program pipeline
Etch marks for up bends are now real geometry entities on an ETCH layer
instead of being drawn dynamically. They flow through the full pipeline:
entities → FilterPanel layers → ConvertGeometry (tagged as Scribe) →
post-processor sequencing before cut geometry.

Also includes ShapeProfile normalization (CW perimeter, CCW cutouts)
applied consistently across all import paths, and inward offset support
for cutout shapes in overlap/offset polygon calculations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 00:42:49 -04:00
aj 80e8693da3 fix: add overlap detection safety net for pair tiling
Shape.OffsetOutward produces inward offsets for certain rotated polygons,
causing geometry-aware copy distances to be too small and placing
overlapping parts. Root cause is in the offset winding direction
detection — this commit adds safety nets while that is investigated.

- FillLinear.FillGrid: detect bbox overlaps after geometry-aware tiling,
  fall back to bbox-based spacing when overlaps found
- FillExtents.RepeatColumns: detect overlaps after Compactor computes
  copy distance, fall back to columnWidth + spacing
- PairFiller/StripeFiller remnant fills: use FillLinear directly instead
  of spawning full engine pipeline (avoids strategies with the bug)
- Add PairOverlapDiagnosticTests reproducing the issue
- MCP config: use shadow-copy wrapper for dev hot-reload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:52:50 -04:00
aj d7eb3ebd7a fix: skip aspect ratio rejection when best-fit utilization is high
High-utilization pairs (>=75%) are no longer discarded for exceeding
the aspect ratio limit, since the material isn't being wasted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:15:45 -04:00
aj 4404d3a5d0 feat: add OpenNest.Data machine configuration system 2026-03-27 20:30:55 -04:00
aj d27dee3db9 feat: add MachineConfigForm editor with tree navigation and MainForm menu integration
Wires the OpenNest.Data layer into the UI: adds project reference, creates MachineConfigForm (tree-based editor for machines/materials/thicknesses with import/export), and adds Tools > Machine Configuration... menu item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:27:44 -04:00
aj 7081c7b4d0 feat: add embedded CL-980 default config with first-run EnsureDefaults
Embeds CL-980.json as a resource in OpenNest.Data and adds EnsureDefaults()
to LocalJsonProvider, which seeds the machines directory on first run when empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:23:00 -04:00
aj a6e813bc85 feat: add IDataProvider interface and LocalJsonProvider with JSON file CRUD
One JSON file per machine named by GUID, stored in a configurable directory.
Supports save, load, list (as summaries), and delete with IO-error retry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:19:56 -04:00
aj 98453243fc feat: add MachineConfig, MaterialConfig, MachineSummary with parameter lookup
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:16:59 -04:00
aj 64874857a1 feat: add LeadConfig, CutOffConfig, and ThicknessConfig data models 2026-03-27 20:14:39 -04:00
aj 5d3fcb2dc8 feat: add OpenNest.Data project with MachineType and UnitSystem enums 2026-03-27 20:13:54 -04:00
aj ae9a63b5ce feat: add Parts/Groups tabs with editable material, thickness, and per-group plate sizes
- Parts tab: shows all BOM items, editable Material/Thickness for
  matched rows, grayed-out rows for items without DXF files
- Groups tab: auto-computed from parts with editable Plate Width/Length
  per material+thickness group
- Editing Material/Thickness on Parts tab immediately re-groups
- Per-group plate sizes preserved across re-groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:50:06 -04:00
aj 596328148d fix: correct dock z-order so Fill control gets remaining space
Fill must be at index 0 (front) so it's processed last by the
docking layout engine. Edge docks at higher indices process first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:07:32 -04:00
aj 6cd48a623d fix: use fixed height for input group instead of AutoSize
AutoSize with Dock.Fill child causes circular sizing and collapses
the GroupBox. Use fixed Height=200 instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:04:43 -04:00
aj 42243c7df0 fix: rewrite BomImportForm layout using TableLayoutPanel for DPI safety
Replaced absolute-positioned controls with TableLayoutPanel in the input
section and Dock-based layout for bottom buttons. Fixes controls being
hidden at non-100% DPI scaling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:58:50 -04:00
aj 4b10d4801c fix: correct dock order and make BomImportForm resizable
- Add Fill control last so edge-docked controls get space first
- Remove stale hardcoded Location on bottom panel
- Switch to Sizable border with MinimumSize so user can resize

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:55:18 -04:00
aj f0bdaa14e6 fix: increase BomImportForm size and enable font auto-scaling
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:50:23 -04:00
aj 79ddce346b fix: move mnuFileImportBom construction before AddRange to avoid null reference
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:47:05 -04:00
aj 20777541c0 fix: address review findings — MdiParent conflict, null guard, Drawing.Material
- Fix critical: use MdiParentForm (custom property) instead of MdiParent
  (WinForms property) in ImportBom_Click to avoid InvalidOperationException
- Add null guard in CreateNests_Click
- Set Drawing.Material from BOM group
- Move DxfImporter creation outside loop
- Improve summary label text with reason descriptions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:40:18 -04:00
aj 7c8168b002 feat: add 'Import BOM...' menu item to MainForm File menu 2026-03-27 17:35:48 -04:00
aj 203bd4eeea feat: add BomImportForm nest creation logic
Task 9: CreateNests_Click validates plate dimensions, then for each
MaterialGroup creates a Nest with name '{job} - {thickness} {material}',
sets PlateDefaults (size, thickness, material, quadrant=1, spacing),
imports each matched DXF via DxfImporter, converts entities to Program
with leading RapidMove offset handling (same pattern as CadConverterForm),
sets Quantity.Required from BOM qty, then opens an EditNestForm for each
nest that has drawings. Summary MessageBox reports count and import errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:34:15 -04:00
aj 02d15dea9c feat: add BomImportForm file browsing and analysis logic
Task 8: BrowseBom_Click auto-fills DXF folder and derives job name by
stripping ' BOM' suffix; BrowseDxf_Click opens folder browser;
Analyze_Click reads BOM via BomReader, runs BomAnalyzer, populates
DataGridView with material/thickness/parts/qty columns, updates summary
label with skipped/unmatched counts, and enables Create Nests button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:33:39 -04:00
aj a88937b716 feat: add BomImportForm designer layout and shell
Task 7: WinForms dialog for BOM import with Input groupbox (job name,
BOM file, DXF folder, plate size, Analyze button), Material Groups
DataGridView, and bottom panel (summary label, Create Nests, Close).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:33:13 -04:00
aj 986a0412b1 feat: add BomAnalyzer — groups BOM items by material+thickness and matches DXFs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:28:45 -04:00
aj e7f2ee80e2 test: add BomAnalyzer tests (red — implementation pending)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 17:27:24 -04:00
aj 31063d954d feat: add Fraction parsing utility for BOM descriptions 2026-03-27 17:24:55 -04:00
aj fc1fee54cd feat: add BomItem model and BomReader Excel parser 2026-03-27 17:24:43 -04:00
aj 094b522644 feat: add ColumnAttribute and CellExtensions for BOM parsing 2026-03-27 17:24:15 -04:00
aj 45dea4ec2b chore: add ClosedXML NuGet package to OpenNest.IO for BOM import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:23:41 -04:00
aj 743bb25f7b chore: add EPPlus NuGet package to OpenNest.IO for BOM import 2026-03-27 17:19:34 -04:00
52 changed files with 3315 additions and 174 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"mcpServers": {
"opennest": {
"command": "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe",
"args": []
"command": "cmd",
"args": ["/c", "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/run.cmd"]
}
}
}
+2 -1
View File
@@ -35,7 +35,8 @@ public static class NestRunner
if (!importer.GetGeometry(part.DxfPath, out var geometry) || geometry.Count == 0)
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
var pgm = ConvertGeometry.ToProgram(geometry);
var normalized = ShapeProfile.NormalizeEntities(geometry);
var pgm = ConvertGeometry.ToProgram(normalized);
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
var drawing = new Drawing(name);
drawing.Program = pgm;
+2 -1
View File
@@ -255,7 +255,8 @@ static class NestConsole
return null;
}
var pgm = ConvertGeometry.ToProgram(geometry);
var normalized = ShapeProfile.NormalizeEntities(geometry);
var pgm = ConvertGeometry.ToProgram(normalized);
if (pgm == null)
{
+56
View File
@@ -1,10 +1,20 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Drawing;
namespace OpenNest.Bending
{
public class Bend
{
public static readonly Layer EtchLayer = new Layer("ETCH")
{
Color = Color.Green,
IsVisible = true
};
private const double DefaultEtchLength = 1.0;
public Vector StartPoint { get; set; }
public Vector EndPoint { get; set; }
public BendDirection Direction { get; set; }
@@ -29,6 +39,52 @@ namespace OpenNest.Bending
/// </summary>
public double LineAngle => StartPoint.AngleTo(EndPoint);
/// <summary>
/// Generates etch mark entities for this bend (up bends only).
/// Returns 1" dashes at each end of the bend line, or the full line if shorter than 3".
/// </summary>
public List<Line> GetEtchEntities(double etchLength = DefaultEtchLength)
{
var result = new List<Line>();
if (Direction != BendDirection.Up)
return result;
var length = Length;
if (length < etchLength * 3.0)
{
result.Add(CreateEtchLine(StartPoint, EndPoint));
}
else
{
var angle = StartPoint.AngleTo(EndPoint);
var dx = System.Math.Cos(angle) * etchLength;
var dy = System.Math.Sin(angle) * etchLength;
result.Add(CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy)));
result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint));
}
return result;
}
/// <summary>
/// Removes existing etch entities from the list and regenerates from the given bends.
/// </summary>
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
{
entities.RemoveAll(e => e.Layer == EtchLayer);
if (bends == null) return;
foreach (var bend in bends)
entities.AddRange(bend.GetEtchEntities());
}
private static Line CreateEtchLine(Vector start, Vector end)
{
return new Line(start, end) { Layer = EtchLayer, Color = Color.Green };
}
public override string ToString()
{
var dir = Direction.ToString();
+4 -1
View File
@@ -108,7 +108,10 @@ namespace OpenNest.Converters
if (line.StartPoint != lastpt)
pgm.MoveTo(line.StartPoint);
pgm.LineTo(line.EndPoint);
var move = new LinearMove(line.EndPoint);
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
move.Layer = LayerType.Scribe;
pgm.Codes.Add(move);
lastpt = line.EndPoint;
return lastpt;
+35
View File
@@ -598,6 +598,41 @@ namespace OpenNest.Geometry
return result;
}
/// <summary>
/// Offsets the shape inward by the given distance.
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
/// making the method independent of the original contour winding direction.
/// </summary>
public Shape OffsetInward(double distance)
{
var poly = ToPolygon();
if (poly == null || poly.Vertices.Count < 3
|| poly.RotationDirection() == RotationType.CCW)
return OffsetEntity(distance, OffsetSide.Left) as Shape;
// Create a reversed copy to avoid mutating shared entity objects.
var copy = new Shape();
for (var i = Entities.Count - 1; i >= 0; i--)
{
switch (Entities[i])
{
case Line l:
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
break;
case Arc a:
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
break;
case Circle c:
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer });
break;
}
}
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
}
/// <summary>
/// Gets the closest point on the shape to the given point.
/// </summary>
+48
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Geometry
{
@@ -41,5 +42,52 @@ namespace OpenNest.Geometry
public Shape Perimeter { get; set; }
public List<Shape> Cutouts { get; set; }
/// <summary>
/// Ensures CNC-standard winding: perimeter CW (kerf left = outward),
/// cutouts CCW (kerf left = inward). Reverses contours in-place as needed.
/// </summary>
public void NormalizeWinding()
{
EnsureWinding(Perimeter, RotationType.CW);
foreach (var cutout in Cutouts)
EnsureWinding(cutout, RotationType.CCW);
}
/// <summary>
/// Returns the entities in normalized winding order (perimeter first, then cutouts).
/// </summary>
public List<Entity> ToNormalizedEntities()
{
NormalizeWinding();
var result = new List<Entity>(Perimeter.Entities);
foreach (var cutout in Cutouts)
result.AddRange(cutout.Entities);
return result;
}
/// <summary>
/// Convenience method: builds a ShapeProfile from raw entities,
/// normalizes winding, and returns the corrected entity list.
/// </summary>
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
{
var profile = new ShapeProfile(entities.ToList());
return profile.ToNormalizedEntities();
}
private static void EnsureWinding(Shape shape, RotationType desired)
{
var poly = shape.ToPolygon();
if (poly != null && poly.Vertices.Count >= 3
&& poly.RotationDirection() != desired)
{
shape.Reverse();
}
}
}
}
+60 -38
View File
@@ -42,23 +42,17 @@ namespace OpenNest
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var profile = new ShapeProfile(
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var lines = new List<Line>();
var totalSpacing = spacing + chordTolerance;
foreach (var shape in shapes)
{
// Add chord tolerance to compensate for inscribed polygon chords
// being inside the actual offset arcs.
var offsetEntity = shape.OffsetOutward(spacing + chordTolerance);
AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
chordTolerance, part.Location);
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(polygon.ToLines());
}
foreach (var cutout in profile.Cutouts)
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location);
return lines;
}
@@ -66,21 +60,17 @@ namespace OpenNest
public static List<Line> GetOffsetPartLines(Part part, double spacing, PushDirection facingDirection, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var profile = new ShapeProfile(
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var lines = new List<Line>();
var totalSpacing = spacing + chordTolerance;
foreach (var shape in shapes)
{
var offsetEntity = shape.OffsetOutward(spacing + chordTolerance);
AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
chordTolerance, part.Location, facingDirection);
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
foreach (var cutout in profile.Cutouts)
AddOffsetDirectionalLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location, facingDirection);
return lines;
}
@@ -104,21 +94,17 @@ namespace OpenNest
public static List<Line> GetOffsetPartLines(Part part, double spacing, Vector facingDirection, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var profile = new ShapeProfile(
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
var lines = new List<Line>();
var totalSpacing = spacing + chordTolerance;
foreach (var shape in shapes)
{
var offsetEntity = shape.OffsetOutward(spacing + chordTolerance);
AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
chordTolerance, part.Location, facingDirection);
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
foreach (var cutout in profile.Cutouts)
AddOffsetDirectionalLines(lines, cutout.OffsetInward(totalSpacing),
chordTolerance, part.Location, facingDirection);
return lines;
}
@@ -189,5 +175,41 @@ namespace OpenNest
return lines;
}
private static void AddOffsetLines(List<Line> lines, Shape offsetEntity,
double chordTolerance, Vector location)
{
if (offsetEntity == null)
return;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(location);
lines.AddRange(polygon.ToLines());
}
private static void AddOffsetDirectionalLines(List<Line> lines, Shape offsetEntity,
double chordTolerance, Vector location, PushDirection facingDirection)
{
if (offsetEntity == null)
return;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
private static void AddOffsetDirectionalLines(List<Line> lines, Shape offsetEntity,
double chordTolerance, Vector location, Vector facingDirection)
{
if (offsetEntity == null)
return;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace OpenNest.Data;
public class CutOffConfig
{
public double PartClearance { get; set; } = 0.02;
public double Overtravel { get; set; }
public double MinSegmentLength { get; set; } = 0.05;
public string Direction { get; set; } = "AwayFromOrigin";
}
+174
View File
@@ -0,0 +1,174 @@
{
"id": "00000000-0000-0000-0000-000000980001",
"schemaVersion": 1,
"name": "CL-980",
"type": "laser",
"units": "inches",
"materials": [
{
"name": "Mild Steel",
"grade": "A36",
"density": 0.2836,
"thicknesses": [
{
"value": 0.060,
"kerf": 0.008,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
"value": 0.075,
"kerf": 0.008,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
"value": 0.105,
"kerf": 0.010,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120" ]
},
{
"value": 0.135,
"kerf": 0.010,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
"value": 0.1875,
"kerf": 0.012,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
"value": 0.250,
"kerf": 0.012,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x120", "60x120", "60x144" ]
},
{
"value": 0.375,
"kerf": 0.016,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.375, "angle": 90.0, "radius": 0.1875 },
"leadOut": { "type": "Line", "length": 0.1875, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.625, "overtravel": 0.3125, "minSegmentLength": 1.25, "direction": "AwayFromOrigin" },
"plateSizes": [ "60x120", "60x144", "72x120" ]
},
{
"value": 0.500,
"kerf": 0.020,
"assistGas": "O2",
"leadIn": { "type": "Arc", "length": 0.5, "angle": 90.0, "radius": 0.25 },
"leadOut": { "type": "Line", "length": 0.25, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.75, "overtravel": 0.375, "minSegmentLength": 1.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "60x120", "60x144", "72x120" ]
}
]
},
{
"name": "Stainless Steel",
"grade": "304",
"density": 0.289,
"thicknesses": [
{
"value": 0.060,
"kerf": 0.008,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.075,
"kerf": 0.008,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.105,
"kerf": 0.010,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.250,
"kerf": 0.014,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
}
]
},
{
"name": "Aluminum",
"grade": "5052",
"density": 0.097,
"thicknesses": [
{
"value": 0.060,
"kerf": 0.008,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.080,
"kerf": 0.008,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.125,
"kerf": 0.010,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
},
{
"value": 0.250,
"kerf": 0.014,
"assistGas": "N2",
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
"plateSizes": [ "48x96", "48x120", "60x120" ]
}
]
}
]
}
+9
View File
@@ -0,0 +1,9 @@
namespace OpenNest.Data;
public interface IDataProvider
{
IReadOnlyList<MachineSummary> GetMachines();
MachineConfig? GetMachine(Guid id);
void SaveMachine(MachineConfig machine);
void DeleteMachine(Guid id);
}
+9
View File
@@ -0,0 +1,9 @@
namespace OpenNest.Data;
public class LeadConfig
{
public string Type { get; set; } = "Line";
public double Length { get; set; }
public double Angle { get; set; } = 90.0;
public double Radius { get; set; }
}
+112
View File
@@ -0,0 +1,112 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OpenNest.Data;
public class LocalJsonProvider : IDataProvider
{
private readonly string _directory;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public LocalJsonProvider(string directory)
{
_directory = directory;
Directory.CreateDirectory(_directory);
}
public IReadOnlyList<MachineSummary> GetMachines()
{
var summaries = new List<MachineSummary>();
foreach (var file in Directory.GetFiles(_directory, "*.json"))
{
var machine = ReadFile(file);
if (machine is not null)
summaries.Add(new MachineSummary(machine.Id, machine.Name));
}
return summaries;
}
public MachineConfig? GetMachine(Guid id)
{
var path = GetPath(id);
return File.Exists(path) ? ReadFile(path) : null;
}
public void SaveMachine(MachineConfig machine)
{
var json = JsonSerializer.Serialize(machine, JsonOptions);
var path = GetPath(machine.Id);
WriteWithRetry(path, json);
}
public void DeleteMachine(Guid id)
{
var path = GetPath(id);
if (File.Exists(path))
File.Delete(path);
}
private string GetPath(Guid id) => Path.Combine(_directory, $"{id}.json");
private static MachineConfig? ReadFile(string path)
{
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<MachineConfig>(json, JsonOptions);
}
catch (JsonException)
{
return null;
}
catch (IOException)
{
return null;
}
}
public void EnsureDefaults()
{
if (Directory.GetFiles(_directory, "*.json").Length > 0)
return;
var assembly = typeof(LocalJsonProvider).Assembly;
var resourceName = assembly.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith("CL-980.json"));
if (resourceName is null) return;
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null) return;
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var config = JsonSerializer.Deserialize<MachineConfig>(json, JsonOptions);
if (config is null) return;
SaveMachine(config);
}
private static void WriteWithRetry(string path, string json, int maxRetries = 3)
{
for (var attempt = 0; attempt < maxRetries; attempt++)
{
try
{
File.WriteAllText(path, json);
return;
}
catch (IOException) when (attempt < maxRetries - 1)
{
Thread.Sleep(100);
}
}
}
}
+26
View File
@@ -0,0 +1,26 @@
using OpenNest.Math;
namespace OpenNest.Data;
public class MachineConfig
{
public Guid Id { get; set; } = Guid.NewGuid();
public int SchemaVersion { get; set; } = 1;
public string Name { get; set; } = "";
public MachineType Type { get; set; } = MachineType.Laser;
public UnitSystem Units { get; set; } = UnitSystem.Inches;
public List<MaterialConfig> Materials { get; set; } = new();
public ThicknessConfig? GetParameters(string material, double thickness)
{
var mat = GetMaterial(material);
if (mat is null) return null;
return mat.Thicknesses.FirstOrDefault(t => t.Value.IsEqualTo(thickness));
}
public MaterialConfig? GetMaterial(string name)
{
return Materials.FirstOrDefault(m =>
string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase));
}
}
+3
View File
@@ -0,0 +1,3 @@
namespace OpenNest.Data;
public record MachineSummary(Guid Id, string Name);
+8
View File
@@ -0,0 +1,8 @@
namespace OpenNest.Data;
public enum MachineType
{
Laser,
Plasma,
Waterjet
}
+9
View File
@@ -0,0 +1,9 @@
namespace OpenNest.Data;
public class MaterialConfig
{
public string Name { get; set; } = "";
public string Grade { get; set; } = "";
public double Density { get; set; }
public List<ThicknessConfig> Thicknesses { get; set; } = new();
}
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.Data</RootNamespace>
<AssemblyName>OpenNest.Data</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Defaults\CL-980.json" />
</ItemGroup>
</Project>
+14
View File
@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace OpenNest.Data;
public class ThicknessConfig
{
public double Value { get; set; }
public double Kerf { get; set; }
public string AssistGas { get; set; } = "";
public LeadConfig LeadIn { get; set; } = new();
public LeadConfig LeadOut { get; set; } = new();
public CutOffConfig CutOff { get; set; } = new();
public List<string> PlateSizes { get; set; } = new();
}
+7
View File
@@ -0,0 +1,7 @@
namespace OpenNest.Data;
public enum UnitSystem
{
Inches,
Millimeters
}
+2 -1
View File
@@ -8,6 +8,7 @@ namespace OpenNest.Engine.BestFit
public double MaxPlateHeight { get; set; }
public double MaxAspectRatio { get; set; } = 5.0;
public double MinUtilization { get; set; } = 0.3;
public double UtilizationOverride { get; set; } = 0.75;
public void Apply(List<BestFitResult> results)
{
@@ -25,7 +26,7 @@ namespace OpenNest.Engine.BestFit
var aspect = result.LongestSide / result.ShortestSide;
if (aspect > MaxAspectRatio)
if (aspect > MaxAspectRatio && result.Utilization < UtilizationOverride)
{
result.Keep = false;
result.Reason = string.Format("Aspect ratio {0:F1} exceeds max {1}", aspect, MaxAspectRatio);
+3 -10
View File
@@ -22,18 +22,11 @@ namespace OpenNest.Engine.BestFit
if (perimeter == null)
return new PolygonExtractionResult(null, Vector.Zero);
// Inflate by half-spacing if spacing is non-zero.
// Detect winding direction to choose the correct outward offset side.
var outwardSide = OffsetSide.Right;
if (halfSpacing > 0)
{
var testPoly = perimeter.ToPolygon();
if (testPoly.Vertices.Count >= 3 && testPoly.RotationDirection() == RotationType.CW)
outwardSide = OffsetSide.Left;
}
// Ensure CW winding for correct outward offset direction.
definedShape.NormalizeWinding();
var inflated = halfSpacing > 0
? (perimeter.OffsetEntity(halfSpacing, outwardSide) as Shape ?? perimeter)
? (perimeter.OffsetOutward(halfSpacing) ?? perimeter)
: perimeter;
// Convert to polygon with circumscribed arcs for tight nesting.
+16
View File
@@ -3,6 +3,7 @@ using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
namespace OpenNest.Engine.Fill
@@ -349,6 +350,21 @@ namespace OpenNest.Engine.Fill
if (copyDistance <= Tolerance.Epsilon)
copyDistance = columnWidth + partSpacing;
// Safety: if the compacted test column overlaps the original column,
// fall back to bbox-based spacing.
var probe = new List<Part>(column);
probe.AddRange(testColumn.Where(IsWithinWorkArea));
if (HasOverlappingParts(probe))
{
Debug.WriteLine($"[FillExtents] Compacted column overlaps, falling back to bbox spacing");
copyDistance = columnWidth + partSpacing;
// Rebuild test column at safe distance.
testColumn.Clear();
foreach (var part in column)
testColumn.Add(part.CloneAtOffset(new Vector(copyDistance, 0)));
}
Debug.WriteLine($"[FillExtents] Column copy distance: {copyDistance:F2} (bbox width: {columnWidth:F2}, spacing: {partSpacing:F2})");
// Build all columns.
+74
View File
@@ -287,6 +287,65 @@ namespace OpenNest.Engine.Fill
return result;
}
/// <summary>
/// Fallback tiling using bounding-box spacing when geometry-aware tiling
/// produces overlapping parts.
/// </summary>
private List<Part> TilePatternBbox(Pattern basePattern, NestDirection direction)
{
var copyDistance = GetDimension(basePattern.BoundingBox, direction) + PartSpacing;
if (copyDistance <= 0)
return new List<Part>();
var dim = GetDimension(basePattern.BoundingBox, direction);
var start = GetStart(basePattern.BoundingBox, direction);
var limit = GetLimit(direction);
var result = new List<Part>();
var count = 1;
while (true)
{
var nextPos = start + copyDistance * count;
if (nextPos + dim > limit + Tolerance.Epsilon)
break;
var offset = MakeOffset(direction, copyDistance * count);
foreach (var part in basePattern.Parts)
result.Add(part.CloneAtOffset(offset));
count++;
}
return result;
}
private static bool HasOverlappingParts(List<Part> parts)
{
for (var i = 0; i < parts.Count; i++)
{
var b1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var b2 = parts[j].BoundingBox;
var overlapX = System.Math.Min(b1.Right, b2.Right)
- System.Math.Max(b1.Left, b2.Left);
var overlapY = System.Math.Min(b1.Top, b2.Top)
- System.Math.Max(b1.Bottom, b2.Bottom);
if (overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon)
return true;
}
}
return false;
}
/// <summary>
/// Creates a seed pattern containing a single part positioned at the work area origin.
/// Returns an empty pattern if the part does not fit.
@@ -325,10 +384,25 @@ namespace OpenNest.Engine.Fill
var row = new List<Part>(pattern.Parts);
row.AddRange(TilePattern(pattern, direction, boundaries));
// Safety: if geometry-aware spacing produced overlapping parts,
// fall back to bbox-based spacing for this axis.
if (pattern.Parts.Count > 1 && HasOverlappingParts(row))
{
row = new List<Part>(pattern.Parts);
row.AddRange(TilePatternBbox(pattern, direction));
}
// If primary tiling didn't produce copies, just tile along perpendicular
if (row.Count <= pattern.Parts.Count)
{
row.AddRange(TilePattern(pattern, perpAxis, boundaries));
if (pattern.Parts.Count > 1 && HasOverlappingParts(row))
{
row = new List<Part>(pattern.Parts);
row.AddRange(TilePatternBbox(pattern, perpAxis));
}
return row;
}
+13 -3
View File
@@ -321,9 +321,19 @@ namespace OpenNest.Engine.Fill
return cachedResult;
}
var remnantEngine = NestEngineRegistry.Create(plate);
var item = new NestItem { Drawing = drawing };
var parts = remnantEngine.Fill(item, remnantBox, null, token);
var filler = new FillLinear(remnantBox, partSpacing);
List<Part> parts = null;
foreach (var angle in new[] { 0.0, Angle.HalfPI })
{
token.ThrowIfCancellationRequested();
var result = FillHelpers.FillWithDirectionPreference(
dir => filler.Fill(drawing, angle, dir),
null, comparer, remnantBox);
if (result != null && result.Count > (parts?.Count ?? 0))
parts = result;
}
Debug.WriteLine($"[PairFiller] Remnant: {parts?.Count ?? 0} parts in " +
$"{remnantBox.Width:F2}x{remnantBox.Length:F2}");
+18 -17
View File
@@ -244,28 +244,29 @@ public class StripeFiller
return cachedResult;
}
FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
try
var filler = new FillLinear(remnantBox, spacing);
List<Part> best = null;
foreach (var angle in new[] { 0.0, Angle.HalfPI })
{
var engine = CreateRemnantEngine(_context.Plate);
var item = new NestItem { Drawing = drawing };
var parts = engine.Fill(item, remnantBox, _context.Progress, _context.Token);
_context.Token.ThrowIfCancellationRequested();
var result = FillHelpers.FillWithDirectionPreference(
dir => filler.Fill(drawing, angle, dir),
null, _comparer, remnantBox);
Debug.WriteLine($"[StripeFiller] Remnant engine ({engine.Name}): {parts?.Count ?? 0} parts, " +
$"winner={engine.WinnerPhase}");
if (parts != null && parts.Count > 0)
{
FillResultCache.Store(drawing, remnantBox, spacing, parts);
return parts;
}
return null;
if (result != null && result.Count > (best?.Count ?? 0))
best = result;
}
finally
Debug.WriteLine($"[StripeFiller] Remnant linear: {best?.Count ?? 0} parts");
if (best != null && best.Count > 0)
{
FillStrategyRegistry.SetEnabled(null);
FillResultCache.Store(drawing, remnantBox, spacing, best);
return best;
}
return null;
}
public static double FindAngleForTargetSpan(
+100
View File
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace OpenNest.IO.Bom
{
public class BomAnalysis
{
public List<MaterialGroup> Groups { get; set; } = new List<MaterialGroup>();
public List<BomItem> Skipped { get; set; } = new List<BomItem>();
public List<BomItem> Unmatched { get; set; } = new List<BomItem>();
}
public class MaterialGroup
{
public string Material { get; set; }
public double Thickness { get; set; }
public List<MatchedPart> Parts { get; set; } = new List<MatchedPart>();
}
public class MatchedPart
{
public BomItem Item { get; set; }
public string DxfPath { get; set; }
}
public static class BomAnalyzer
{
public static BomAnalysis Analyze(List<BomItem> items, string dxfFolder)
{
var result = new BomAnalysis();
// Build a case-insensitive lookup of DXF files in the folder (if it exists)
var folderExists = Directory.Exists(dxfFolder);
var dxfFiles = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (folderExists)
{
foreach (var file in Directory.GetFiles(dxfFolder, "*.dxf"))
{
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
dxfFiles[nameWithoutExt] = file;
}
}
// Partition items into: skipped, unmatched, or matched (grouped)
var matched = new List<MatchedPart>();
foreach (var item in items)
{
if (string.IsNullOrWhiteSpace(item.FileName) || !item.Thickness.HasValue)
{
result.Skipped.Add(item);
continue;
}
var lookupName = item.FileName;
// Strip .dxf extension if the BOM includes it
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (!folderExists)
{
// No folder to search — group items without a DXF path
matched.Add(new MatchedPart { Item = item, DxfPath = null });
}
else if (dxfFiles.TryGetValue(lookupName, out var dxfPath))
{
matched.Add(new MatchedPart { Item = item, DxfPath = dxfPath });
}
else
{
result.Unmatched.Add(item);
}
}
// Group matched parts by material + thickness
var groups = matched
.GroupBy(p => new
{
Material = (p.Item.Material ?? "").ToUpperInvariant(),
Thickness = p.Item.Thickness.Value
})
.Select(g => new MaterialGroup
{
Material = g.First().Item.Material ?? "",
Thickness = g.Key.Thickness,
Parts = g.ToList()
})
.OrderBy(g => g.Material)
.ThenBy(g => g.Thickness)
.ToList();
result.Groups = groups;
return result;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
namespace OpenNest.IO.Bom
{
public class BomItem
{
[Column("Item #", "Item Number", "Item Num")]
public int? ItemNum { get; set; }
[Column("File Name")]
public string FileName { get; set; }
[Column("Qty", "Quantity")]
public int? Qty { get; set; }
[Column("Description")]
public string Description { get; set; }
[Column("Part", "Part Name")]
public string PartName { get; set; }
[Column("Config", "Configuration")]
public string ConfigurationName { get; set; }
[Column("Thickness")]
public double? Thickness { get; set; }
[Column("Material")]
public string Material { get; set; }
[Column("K-Factor")]
public double? KFactor { get; set; }
[Column("Default Bend Radius")]
public double? DefaultBendRadius { get; set; }
}
}
+99
View File
@@ -0,0 +1,99 @@
using ClosedXML.Excel;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace OpenNest.IO.Bom
{
public class BomReader : IDisposable
{
private readonly XLWorkbook workbook;
private Dictionary<PropertyInfo, int> columnNameIndexDict;
public BomReader(string file)
{
workbook = new XLWorkbook(file);
columnNameIndexDict = new Dictionary<PropertyInfo, int>();
}
private IXLWorksheet GetPartsWorksheet()
{
if (!workbook.TryGetWorksheet("Parts", out var worksheet))
throw new InvalidOperationException("BOM file does not contain a 'Parts' worksheet.");
return worksheet;
}
private void FindColumnIndexes(IXLWorksheet worksheet)
{
var lastColumn = worksheet.LastColumnUsed()?.ColumnNumber() ?? 0;
var properties = typeof(BomItem).GetProperties();
foreach (var property in properties)
{
var column = property.GetCustomAttribute<ColumnAttribute>();
if (column == null)
continue;
var classColumnNames = column.Names.Select(n => n.ToUpper());
for (var columnIndex = 1; columnIndex <= lastColumn; columnIndex++)
{
var cell = worksheet.Cell(1, columnIndex);
if (cell.IsEmpty()) continue;
var excelColumnName = cell.GetString().ToUpper();
var isMatch = classColumnNames.Any(n => n == excelColumnName);
if (!isMatch)
continue;
columnNameIndexDict.Add(property, columnIndex);
break;
}
}
}
public List<BomItem> GetItems()
{
var worksheet = GetPartsWorksheet();
FindColumnIndexes(worksheet);
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;
var items = new List<BomItem>();
for (var rowIndex = 2; rowIndex <= lastRow; rowIndex++)
{
var item = new BomItem();
foreach (var dictItem in columnNameIndexDict)
{
var property = dictItem.Key;
var excelColumnIndex = dictItem.Value;
var cell = worksheet.Cell(rowIndex, excelColumnIndex);
var type = property.PropertyType;
if (type == typeof(int?))
property.SetValue(item, cell.ToIntOrNull());
else if (type == typeof(string))
property.SetValue(item, cell.IsEmpty() ? null : cell.GetString());
else if (type == typeof(double?))
property.SetValue(item, cell.ToDoubleOrNull());
else
throw new NotImplementedException($"Unsupported property type: {type}");
}
items.Add(item);
}
return items;
}
public void Dispose()
{
workbook?.Dispose();
}
}
}
+23
View File
@@ -0,0 +1,23 @@
using ClosedXML.Excel;
namespace OpenNest.IO.Bom
{
public static class CellExtensions
{
public static int? ToIntOrNull(this IXLCell cell)
{
if (cell.IsEmpty()) return null;
if (cell.DataType == XLDataType.Number) return (int)cell.GetDouble();
if (int.TryParse(cell.GetString(), out var i)) return i;
return null;
}
public static double? ToDoubleOrNull(this IXLCell cell)
{
if (cell.IsEmpty()) return null;
if (cell.DataType == XLDataType.Number) return cell.GetDouble();
if (double.TryParse(cell.GetString(), out var result)) return result;
return null;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
using System;
namespace OpenNest.IO.Bom
{
[AttributeUsage(AttributeTargets.Property)]
public class ColumnAttribute : Attribute
{
public ColumnAttribute(params string[] names)
{
Names = names;
}
public string[] Names { get; }
}
}
+76
View File
@@ -0,0 +1,76 @@
using System;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace OpenNest.IO.Bom
{
public static class Fraction
{
public static readonly Regex FractionRegex =
new Regex(@"((?<WholeNum>\d+)(\ |-))?(?<Fraction>\d+\/\d+)");
public static bool IsValid(string s)
{
return FractionRegex.IsMatch(s);
}
public static double Parse(string s)
{
var match = FractionRegex.Match(s);
if (!match.Success)
throw new FormatException("Invalid fraction format.");
var value = 0.0;
var wholeNumGroup = match.Groups["WholeNum"];
var fractionGroup = match.Groups["Fraction"];
if (wholeNumGroup.Success)
value = double.Parse(wholeNumGroup.Value);
if (fractionGroup.Success)
{
var parts = fractionGroup.Value.Split('/');
var numerator = double.Parse(parts[0]);
var denominator = double.Parse(parts[1]);
value += System.Math.Round(numerator / denominator, 8);
}
return value;
}
public static bool TryParse(string s, out double fraction)
{
try
{
fraction = Parse(s);
return true;
}
catch
{
fraction = 0;
return false;
}
}
public static string ReplaceFractionsWithDecimals(string input)
{
var sb = new StringBuilder(input);
var fractionMatches = FractionRegex.Matches(sb.ToString())
.Cast<Match>()
.OrderByDescending(m => m.Index);
foreach (var fractionMatch in fractionMatches)
{
var decimalValue = Parse(fractionMatch.Value);
sb.Remove(fractionMatch.Index, fractionMatch.Length);
sb.Insert(fractionMatch.Index, decimalValue);
}
return sb.ToString();
}
}
}
+1
View File
@@ -8,5 +8,6 @@
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<PackageReference Include="ACadSharp" Version="3.1.32" />
<PackageReference Include="ClosedXML" Version="0.104.2" />
</ItemGroup>
</Project>
+3 -1
View File
@@ -1,5 +1,6 @@
using ModelContextProtocol.Server;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Shapes;
using System.ComponentModel;
@@ -103,7 +104,8 @@ namespace OpenNest.Mcp.Tools
if (geometry.Count == 0)
return "Error: no geometry found in DXF file";
var pgm = ConvertGeometry.ToProgram(geometry);
var normalized = ShapeProfile.NormalizeEntities(geometry);
var pgm = ConvertGeometry.ToProgram(normalized);
if (pgm == null)
return "Error: failed to convert geometry to program";
+168
View File
@@ -0,0 +1,168 @@
namespace OpenNest.Tests.Bom;
using OpenNest.IO.Bom;
public class BomAnalyzerTests
{
[Fact]
public void Analyze_GroupsByMaterialAndThickness()
{
var items = new List<BomItem>
{
new BomItem { FileName = "PT01", Thickness = 0.25, Material = "AISI 304", Qty = 2 },
new BomItem { FileName = "PT02", Thickness = 0.25, Material = "AISI 304", Qty = 3 },
new BomItem { FileName = "PT03", Thickness = 0.375, Material = "AISI 304", Qty = 1 },
};
var result = BomAnalyzer.Analyze(items, "C:\\fake");
Assert.Equal(2, result.Groups.Count);
Assert.Single(result.Groups, g => g.Thickness == 0.25 && g.Material == "AISI 304");
Assert.Single(result.Groups, g => g.Thickness == 0.375 && g.Material == "AISI 304");
}
[Fact]
public void Analyze_SkipsItemsWithNoFileName()
{
var items = new List<BomItem>
{
new BomItem { FileName = "PT01", Thickness = 0.25, Material = "AISI 304", Qty = 2 },
new BomItem { FileName = null, Thickness = 0.25, Material = "AISI 304", Qty = 3 },
new BomItem { FileName = "", Thickness = 0.25, Material = "AISI 304", Qty = 1 },
};
var result = BomAnalyzer.Analyze(items, "C:\\fake");
Assert.Equal(2, result.Skipped.Count);
}
[Fact]
public void Analyze_SkipsItemsWithNoThickness()
{
var items = new List<BomItem>
{
new BomItem { FileName = "PT01", Thickness = 0.25, Material = "AISI 304", Qty = 2 },
new BomItem { FileName = "PT02", Thickness = null, Material = "AISI 304", Qty = 3 },
};
var result = BomAnalyzer.Analyze(items, "C:\\fake");
Assert.Single(result.Skipped);
Assert.Equal("PT02", result.Skipped[0].FileName);
}
[Fact]
public void Analyze_GroupsMaterialCaseInsensitive()
{
var items = new List<BomItem>
{
new BomItem { FileName = "PT01", Thickness = 0.25, Material = "AISI 304", Qty = 1 },
new BomItem { FileName = "PT02", Thickness = 0.25, Material = "aisi 304", Qty = 1 },
};
var result = BomAnalyzer.Analyze(items, "C:\\fake");
Assert.Single(result.Groups);
Assert.Equal(2, result.Groups[0].Parts.Count);
}
[Fact]
public void Analyze_MatchesDxfFiles_WithAndWithoutExtension()
{
var tempDir = Path.Combine(Path.GetTempPath(), "BomAnalyzerTest_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "PT01.dxf"), "");
var items = new List<BomItem>
{
new BomItem { FileName = "PT01", Thickness = 0.25, Material = "AISI 304", Qty = 2 },
};
var result = BomAnalyzer.Analyze(items, tempDir);
Assert.Single(result.Groups);
Assert.Single(result.Groups[0].Parts);
Assert.EndsWith(".dxf", result.Groups[0].Parts[0].DxfPath, StringComparison.OrdinalIgnoreCase);
Assert.Empty(result.Unmatched);
}
finally
{
Directory.Delete(tempDir, true);
}
}
[Fact]
public void Analyze_ReportsUnmatchedItems_WhenDxfNotFound()
{
var tempDir = Path.Combine(Path.GetTempPath(), "BomAnalyzerTest_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
var items = new List<BomItem>
{
new BomItem { FileName = "PT99", Thickness = 0.25, Material = "AISI 304", Qty = 1 },
};
var result = BomAnalyzer.Analyze(items, tempDir);
Assert.Single(result.Unmatched);
Assert.Empty(result.Groups);
}
finally
{
Directory.Delete(tempDir, true);
}
}
[Fact]
public void Analyze_DifferentMaterials_SameThickness_SeparateGroups()
{
var items = new List<BomItem>
{
new BomItem { FileName = "PT01", Thickness = 0.25, Material = "AISI 304", Qty = 1 },
new BomItem { FileName = "PT02", Thickness = 0.25, Material = "Plain Carbon Steel", Qty = 1 },
};
var result = BomAnalyzer.Analyze(items, "C:\\fake");
Assert.Equal(2, result.Groups.Count);
}
[Fact]
public void Analyze_GroupPartsCount_MatchesBomItems()
{
var tempDir = Path.Combine(Path.GetTempPath(), "BomAnalyzerTest_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
try
{
File.WriteAllText(Path.Combine(tempDir, "PT01.dxf"), "");
File.WriteAllText(Path.Combine(tempDir, "PT02.dxf"), "");
File.WriteAllText(Path.Combine(tempDir, "PT03.dxf"), "");
var items = new List<BomItem>
{
new BomItem { FileName = "PT01", Thickness = 0.25, Material = "AISI 304", Qty = 2 },
new BomItem { FileName = "PT02", Thickness = 0.25, Material = "AISI 304", Qty = 5 },
new BomItem { FileName = "PT03", Thickness = 0.375, Material = "AISI 304", Qty = 1 },
};
var result = BomAnalyzer.Analyze(items, tempDir);
var quarterGroup = result.Groups.First(g => g.Thickness == 0.25);
Assert.Equal(2, quarterGroup.Parts.Count);
Assert.Equal(2, quarterGroup.Parts.First(p => p.Item.FileName == "PT01").Item.Qty);
var threeEighthsGroup = result.Groups.First(g => g.Thickness == 0.375);
Assert.Single(threeEighthsGroup.Parts);
}
finally
{
Directory.Delete(tempDir, true);
}
}
}
+75
View File
@@ -0,0 +1,75 @@
using OpenNest.Data;
namespace OpenNest.Tests.Data;
public class DefaultConfigTests : IDisposable
{
private readonly string _testDir;
public DefaultConfigTests()
{
_testDir = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
}
public void Dispose()
{
if (Directory.Exists(_testDir))
Directory.Delete(_testDir, true);
}
[Fact]
public void EnsureDefaults_EmptyDirectory_CopiesDefaultConfig()
{
var provider = new LocalJsonProvider(_testDir);
provider.EnsureDefaults();
var machines = provider.GetMachines();
Assert.Single(machines);
Assert.Equal("CL-980", machines[0].Name);
}
[Fact]
public void EnsureDefaults_ExistingFiles_DoesNotCopy()
{
var provider = new LocalJsonProvider(_testDir);
var existing = new MachineConfig { Name = "My Machine" };
provider.SaveMachine(existing);
provider.EnsureDefaults();
var machines = provider.GetMachines();
Assert.Single(machines);
Assert.Equal("My Machine", machines[0].Name);
}
[Fact]
public void DefaultConfig_HasValidStructure()
{
var provider = new LocalJsonProvider(_testDir);
provider.EnsureDefaults();
var machines = provider.GetMachines();
var config = provider.GetMachine(machines[0].Id);
Assert.NotNull(config);
Assert.Equal(1, config.SchemaVersion);
Assert.Equal(MachineType.Laser, config.Type);
Assert.Equal(UnitSystem.Inches, config.Units);
Assert.NotEmpty(config.Materials);
foreach (var material in config.Materials)
{
Assert.False(string.IsNullOrWhiteSpace(material.Name));
Assert.NotEmpty(material.Thicknesses);
foreach (var thickness in material.Thicknesses)
{
Assert.True(thickness.Value > 0);
Assert.True(thickness.Kerf > 0);
Assert.False(string.IsNullOrWhiteSpace(thickness.AssistGas));
Assert.NotNull(thickness.LeadIn);
Assert.NotNull(thickness.LeadOut);
Assert.NotNull(thickness.CutOff);
}
}
}
}
@@ -0,0 +1,163 @@
using OpenNest.Data;
namespace OpenNest.Tests.Data;
public class LocalJsonProviderTests : IDisposable
{
private readonly string _testDir;
public LocalJsonProviderTests()
{
_testDir = Path.Combine(Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString());
Directory.CreateDirectory(_testDir);
}
public void Dispose()
{
if (Directory.Exists(_testDir))
Directory.Delete(_testDir, true);
}
private LocalJsonProvider CreateProvider() => new(_testDir);
[Fact]
public void GetMachines_EmptyDirectory_ReturnsEmpty()
{
var provider = CreateProvider();
var result = provider.GetMachines();
Assert.Empty(result);
}
[Fact]
public void SaveMachine_ThenGetMachine_RoundTrips()
{
var provider = CreateProvider();
var machine = new MachineConfig
{
Name = "Test Laser",
Type = MachineType.Laser,
Units = UnitSystem.Inches,
Materials = new List<MaterialConfig>
{
new()
{
Name = "Mild Steel",
Grade = "A36",
Density = 0.2836,
Thicknesses = new List<ThicknessConfig>
{
new()
{
Value = 0.250,
Kerf = 0.012,
AssistGas = "O2",
LeadIn = new LeadConfig { Type = "Arc", Length = 0.25, Angle = 90.0, Radius = 0.125 },
LeadOut = new LeadConfig { Type = "Line", Length = 0.125 },
CutOff = new CutOffConfig
{
PartClearance = 0.5,
Overtravel = 0.25,
Direction = "AwayFromOrigin",
MinSegmentLength = 1.0
},
PlateSizes = new List<string> { "60x120", "48x96" }
}
}
}
}
};
provider.SaveMachine(machine);
var loaded = provider.GetMachine(machine.Id);
Assert.NotNull(loaded);
Assert.Equal("Test Laser", loaded.Name);
Assert.Equal(MachineType.Laser, loaded.Type);
Assert.Equal(UnitSystem.Inches, loaded.Units);
Assert.Single(loaded.Materials);
var mat = loaded.Materials[0];
Assert.Equal("Mild Steel", mat.Name);
Assert.Equal("A36", mat.Grade);
Assert.Equal(0.2836, mat.Density);
Assert.Single(mat.Thicknesses);
var thick = mat.Thicknesses[0];
Assert.Equal(0.250, thick.Value);
Assert.Equal(0.012, thick.Kerf);
Assert.Equal("O2", thick.AssistGas);
Assert.Equal("Arc", thick.LeadIn.Type);
Assert.Equal(0.25, thick.LeadIn.Length);
Assert.Equal("Line", thick.LeadOut.Type);
Assert.Equal(0.125, thick.LeadOut.Length);
Assert.Equal(0.5, thick.CutOff.PartClearance);
Assert.Equal("AwayFromOrigin", thick.CutOff.Direction);
Assert.Equal(new List<string> { "60x120", "48x96" }, thick.PlateSizes);
}
[Fact]
public void GetMachines_ReturnsSummaries()
{
var provider = CreateProvider();
var m1 = new MachineConfig { Name = "Laser A" };
var m2 = new MachineConfig { Name = "Plasma B" };
provider.SaveMachine(m1);
provider.SaveMachine(m2);
var summaries = provider.GetMachines();
Assert.Equal(2, summaries.Count);
Assert.Contains(summaries, s => s.Name == "Laser A" && s.Id == m1.Id);
Assert.Contains(summaries, s => s.Name == "Plasma B" && s.Id == m2.Id);
}
[Fact]
public void GetMachine_NotFound_ReturnsNull()
{
var provider = CreateProvider();
var result = provider.GetMachine(Guid.NewGuid());
Assert.Null(result);
}
[Fact]
public void DeleteMachine_RemovesFile()
{
var provider = CreateProvider();
var machine = new MachineConfig { Name = "To Delete" };
provider.SaveMachine(machine);
provider.DeleteMachine(machine.Id);
Assert.Null(provider.GetMachine(machine.Id));
Assert.Empty(provider.GetMachines());
}
[Fact]
public void SaveMachine_OverwritesExisting()
{
var provider = CreateProvider();
var machine = new MachineConfig { Name = "Original" };
provider.SaveMachine(machine);
machine.Name = "Updated";
provider.SaveMachine(machine);
var loaded = provider.GetMachine(machine.Id);
Assert.NotNull(loaded);
Assert.Equal("Updated", loaded.Name);
Assert.Single(provider.GetMachines());
}
[Fact]
public void SaveMachine_PreservesSchemaVersion()
{
var provider = CreateProvider();
var machine = new MachineConfig { Name = "Versioned", SchemaVersion = 1 };
provider.SaveMachine(machine);
var loaded = provider.GetMachine(machine.Id);
Assert.NotNull(loaded);
Assert.Equal(1, loaded.SchemaVersion);
}
}
+131
View File
@@ -0,0 +1,131 @@
using OpenNest.Data;
namespace OpenNest.Tests.Data;
public class MachineConfigTests
{
private static MachineConfig CreateTestMachine()
{
return new MachineConfig
{
Id = Guid.NewGuid(),
Name = "Test Laser",
Type = MachineType.Laser,
Units = UnitSystem.Inches,
Materials = new List<MaterialConfig>
{
new()
{
Name = "Mild Steel",
Grade = "A36",
Density = 0.2836,
Thicknesses = new List<ThicknessConfig>
{
new()
{
Value = 0.250,
Kerf = 0.012,
AssistGas = "O2",
LeadIn = new LeadConfig { Type = "Arc", Radius = 0.25 },
LeadOut = new LeadConfig { Type = "Line", Length = 0.125 },
PlateSizes = new List<string> { "60x120", "48x96" }
},
new()
{
Value = 0.500,
Kerf = 0.020,
AssistGas = "O2",
LeadIn = new LeadConfig { Type = "Arc", Radius = 0.375 },
LeadOut = new LeadConfig { Type = "Line", Length = 0.25 },
PlateSizes = new List<string> { "60x120" }
}
}
},
new()
{
Name = "Stainless Steel",
Grade = "304",
Density = 0.289,
Thicknesses = new List<ThicknessConfig>
{
new()
{
Value = 0.250,
Kerf = 0.014,
AssistGas = "N2"
}
}
}
}
};
}
[Fact]
public void GetParameters_ExactMatch_ReturnsThickness()
{
var machine = CreateTestMachine();
var result = machine.GetParameters("Mild Steel", 0.250);
Assert.NotNull(result);
Assert.Equal(0.012, result.Kerf);
Assert.Equal("O2", result.AssistGas);
}
[Fact]
public void GetParameters_WithinTolerance_ReturnsThickness()
{
var machine = CreateTestMachine();
var result = machine.GetParameters("Mild Steel", 0.250001);
Assert.NotNull(result);
Assert.Equal(0.012, result.Kerf);
}
[Fact]
public void GetParameters_NoMatch_ReturnsNull()
{
var machine = CreateTestMachine();
var result = machine.GetParameters("Mild Steel", 0.375);
Assert.Null(result);
}
[Fact]
public void GetParameters_CaseInsensitiveMaterial()
{
var machine = CreateTestMachine();
var result = machine.GetParameters("mild steel", 0.250);
Assert.NotNull(result);
Assert.Equal(0.012, result.Kerf);
}
[Fact]
public void GetParameters_UnknownMaterial_ReturnsNull()
{
var machine = CreateTestMachine();
var result = machine.GetParameters("Titanium", 0.250);
Assert.Null(result);
}
[Fact]
public void GetMaterial_ReturnsMaterialByName()
{
var machine = CreateTestMachine();
var result = machine.GetMaterial("Stainless Steel");
Assert.NotNull(result);
Assert.Equal("304", result.Grade);
}
[Fact]
public void GetMaterial_CaseInsensitive()
{
var machine = CreateTestMachine();
var result = machine.GetMaterial("stainless steel");
Assert.NotNull(result);
}
[Fact]
public void GetMaterial_NotFound_ReturnsNull()
{
var machine = CreateTestMachine();
var result = machine.GetMaterial("Titanium");
Assert.Null(result);
}
}
+1
View File
@@ -22,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
@@ -0,0 +1,238 @@
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using OpenNest.Math;
using Xunit.Abstractions;
namespace OpenNest.Tests;
public class PairOverlapDiagnosticTests
{
private readonly ITestOutputHelper _output;
public PairOverlapDiagnosticTests(ITestOutputHelper output) => _output = output;
/// <summary>
/// Creates a 5x3.31 rectangle with rounded corners on the top-right and bottom-right
/// (radius 0.5), similar to "4526 A14 PT13".
/// </summary>
private static Drawing MakeRoundedRect(double w = 5.0, double h = 3.31, double r = 0.5)
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
// Bottom edge
pgm.Codes.Add(new LinearMove(new Vector(w - r, 0)));
// Bottom-right rounded corner
pgm.Codes.Add(new ArcMove(new Vector(w, r), new Vector(w - r, r), RotationType.CW));
// Right edge
pgm.Codes.Add(new LinearMove(new Vector(w, h - r)));
// Top-right rounded corner
pgm.Codes.Add(new ArcMove(new Vector(w - r, h), new Vector(w - r, h - r), RotationType.CW));
// Top edge
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
// Left edge back to start
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return new Drawing("rounded-rect", pgm);
}
private static Drawing MakeSimpleRect(double w = 5.0, double h = 3.31)
{
var pgm = new 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);
}
[Theory]
[InlineData(0)] // 0 degrees
[InlineData(90)] // 90 degrees
[InlineData(180)] // 180 degrees
[InlineData(270)] // 270 degrees
public void PartBoundary_HasEdgesAtAllRotations_RoundedRect(double angleDeg)
{
var drawing = MakeRoundedRect();
var part = new Part(drawing);
if (angleDeg != 0)
part.Rotate(Angle.ToRadians(angleDeg));
var boundary = new PartBoundary(part, 0.125);
var left = boundary.GetEdges(PushDirection.Left);
var right = boundary.GetEdges(PushDirection.Right);
var up = boundary.GetEdges(PushDirection.Up);
var down = boundary.GetEdges(PushDirection.Down);
_output.WriteLine($"Rotation: {angleDeg}°");
_output.WriteLine($" Left edges: {left.Length}");
_output.WriteLine($" Right edges: {right.Length}");
_output.WriteLine($" Up edges: {up.Length}");
_output.WriteLine($" Down edges: {down.Length}");
Assert.True(left.Length > 0, $"No left edges at {angleDeg}°");
Assert.True(right.Length > 0, $"No right edges at {angleDeg}°");
Assert.True(up.Length > 0, $"No up edges at {angleDeg}°");
Assert.True(down.Length > 0, $"No down edges at {angleDeg}°");
}
[Theory]
[InlineData(0)]
[InlineData(90)]
[InlineData(180)]
[InlineData(270)]
public void PartBoundary_HasEdgesAtAllRotations_SimpleRect(double angleDeg)
{
var drawing = MakeSimpleRect();
var part = new Part(drawing);
if (angleDeg != 0)
part.Rotate(Angle.ToRadians(angleDeg));
var boundary = new PartBoundary(part, 0.125);
var left = boundary.GetEdges(PushDirection.Left);
var right = boundary.GetEdges(PushDirection.Right);
var up = boundary.GetEdges(PushDirection.Up);
var down = boundary.GetEdges(PushDirection.Down);
_output.WriteLine($"Rotation: {angleDeg}°");
_output.WriteLine($" Left edges: {left.Length}");
_output.WriteLine($" Right edges: {right.Length}");
_output.WriteLine($" Up edges: {up.Length}");
_output.WriteLine($" Down edges: {down.Length}");
Assert.True(left.Length > 0, $"No left edges at {angleDeg}°");
Assert.True(right.Length > 0, $"No right edges at {angleDeg}°");
Assert.True(up.Length > 0, $"No up edges at {angleDeg}°");
Assert.True(down.Length > 0, $"No down edges at {angleDeg}°");
}
[Theory]
[InlineData(false)] // simple rect
[InlineData(true)] // rounded rect
public void FillExtents_NoPairOverlap_At90Degrees(bool rounded)
{
var drawing = rounded ? MakeRoundedRect() : MakeSimpleRect();
var workArea = new Box(0, 0, 20, 20);
var partSpacing = 0.25;
var filler = new FillExtents(workArea, partSpacing);
var parts = filler.Fill(drawing, Angle.ToRadians(90));
_output.WriteLine($"Shape: {(rounded ? "rounded rect" : "simple rect")}");
_output.WriteLine($"Parts: {parts.Count}");
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
_output.WriteLine($" [{i}] rot={Angle.ToDegrees(p.Rotation):F1}° " +
$"bbox=({p.BoundingBox.Left:F2},{p.BoundingBox.Bottom:F2})-({p.BoundingBox.Right:F2},{p.BoundingBox.Top:F2})");
}
// Check for overlapping bounding boxes
for (var i = 0; i < parts.Count; i++)
{
var b1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var b2 = parts[j].BoundingBox;
var overlapX = System.Math.Min(b1.Right, b2.Right) - System.Math.Max(b1.Left, b2.Left);
var overlapY = System.Math.Min(b1.Top, b2.Top) - System.Math.Max(b1.Bottom, b2.Bottom);
if (overlapX > 0.01 && overlapY > 0.01)
_output.WriteLine($" OVERLAP: [{i}] and [{j}] overlap by ({overlapX:F3}, {overlapY:F3})");
Assert.False(overlapX > 0.01 && overlapY > 0.01,
$"Parts [{i}] and [{j}] have overlapping bounding boxes " +
$"({overlapX:F3} x {overlapY:F3})");
}
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void FillLinear_PairPattern_NoPairOverlap_At90Degrees(bool rounded)
{
var drawing = rounded ? MakeRoundedRect() : MakeSimpleRect();
var workArea = new Box(0, 0, 20, 20);
var partSpacing = 0.25;
// Build a pair at 90°/270°
var part1 = Part.CreateAtOrigin(drawing, Angle.ToRadians(90));
var part2 = Part.CreateAtOrigin(drawing, Angle.ToRadians(270));
// Slide part2 right of part1
var offset = part1.BoundingBox.Width + part2.BoundingBox.Width + partSpacing;
part2.Offset(offset, 0);
part2.UpdateBounds();
// Slide part2 left toward part1 using geometry
var b1 = new PartBoundary(part1, partSpacing / 2);
var b2 = new PartBoundary(part2, partSpacing / 2);
_output.WriteLine($"Part1 (90°) boundary edges: L={b1.GetEdges(PushDirection.Left).Length} R={b1.GetEdges(PushDirection.Right).Length}");
_output.WriteLine($"Part2 (270°) boundary edges: L={b2.GetEdges(PushDirection.Left).Length} R={b2.GetEdges(PushDirection.Right).Length}");
var movingLines = b2.GetLines(part2.Location, PushDirection.Left);
var stationaryLines = b1.GetLines(part1.Location, PushDirection.Right);
_output.WriteLine($"Part1 loc: ({part1.Location.X:F4},{part1.Location.Y:F4})");
_output.WriteLine($"Part2 loc: ({part2.Location.X:F4},{part2.Location.Y:F4})");
_output.WriteLine($"Moving lines (part2 left): {movingLines.Count}");
foreach (var l in movingLines)
_output.WriteLine($" ({l.pt1.X:F4},{l.pt1.Y:F4})->({l.pt2.X:F4},{l.pt2.Y:F4})");
_output.WriteLine($"Stationary lines (part1 right): {stationaryLines.Count}");
foreach (var l in stationaryLines)
_output.WriteLine($" ({l.pt1.X:F4},{l.pt1.Y:F4})->({l.pt2.X:F4},{l.pt2.Y:F4})");
var slideDist = SpatialQuery.DirectionalDistance(movingLines, stationaryLines, PushDirection.Left);
_output.WriteLine($"Slide distance: {slideDist:F4}");
if (slideDist < double.MaxValue && slideDist > 0)
{
part2.Offset(-slideDist, 0);
part2.UpdateBounds();
}
_output.WriteLine($"Part1 bbox: ({part1.BoundingBox.Left:F2},{part1.BoundingBox.Bottom:F2})-({part1.BoundingBox.Right:F2},{part1.BoundingBox.Top:F2})");
_output.WriteLine($"Part2 bbox: ({part2.BoundingBox.Left:F2},{part2.BoundingBox.Bottom:F2})-({part2.BoundingBox.Right:F2},{part2.BoundingBox.Top:F2})");
// Now tile this pair pattern
var pattern = new Pattern();
pattern.Parts.Add(part1);
pattern.Parts.Add(part2);
pattern.UpdateBounds();
_output.WriteLine($"Pattern bbox width: {pattern.BoundingBox.Width:F2}");
var engine = new FillLinear(workArea, partSpacing);
var parts = engine.Fill(pattern, NestDirection.Horizontal);
_output.WriteLine($"Total parts: {parts.Count}");
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
_output.WriteLine($" [{i}] rot={Angle.ToDegrees(p.Rotation):F1}° " +
$"bbox=({p.BoundingBox.Left:F2},{p.BoundingBox.Bottom:F2})-({p.BoundingBox.Right:F2},{p.BoundingBox.Top:F2})");
}
// Check for overlaps
for (var i = 0; i < parts.Count; i++)
{
var bi = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var bj = parts[j].BoundingBox;
var ox = System.Math.Min(bi.Right, bj.Right) - System.Math.Max(bi.Left, bj.Left);
var oy = System.Math.Min(bi.Top, bj.Top) - System.Math.Max(bi.Bottom, bj.Bottom);
Assert.False(ox > 0.01 && oy > 0.01,
$"Parts [{i}] and [{j}] overlap ({ox:F3} x {oy:F3})");
}
}
}
}
+2 -1
View File
@@ -151,7 +151,8 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
}
var drawing = new Drawing(Path.GetFileName(file));
drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(entities);
var normalized = ShapeProfile.NormalizeEntities(entities);
drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(normalized);
drawing.UpdateArea();
drawing.Color = PartColors[colorIndex % PartColors.Length];
colorIndex++;
+14
View File
@@ -30,6 +30,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PostProcessors", "PostProce
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.Cincinnati", "OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj", "{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -172,6 +174,18 @@ Global
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x64.Build.0 = Release|Any CPU
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.ActiveCfg = Release|Any CPU
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.Build.0 = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.ActiveCfg = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.Build.0 = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.ActiveCfg = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x86.Build.0 = Debug|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|Any CPU.Build.0 = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.ActiveCfg = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.Build.0 = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.ActiveCfg = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
-42
View File
@@ -112,8 +112,6 @@ namespace OpenNest.Controls
DrawEntity(e.Graphics, entity, pen);
}
DrawEtchMarks(e.Graphics);
if (SimplifierPreview != null)
{
// Draw tolerance zone (offset lines each side of original geometry)
@@ -240,46 +238,6 @@ namespace OpenNest.Controls
private static bool IsEtchLayer(Layer layer) =>
string.Equals(layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase);
private void DrawEtchMarks(Graphics g)
{
if (Bends == null || Bends.Count == 0)
return;
using var etchPen = new Pen(Color.Green, 1.5f);
var etchLength = 1.0;
foreach (var bend in Bends)
{
if (bend.Direction != BendDirection.Up)
continue;
var start = bend.StartPoint;
var end = bend.EndPoint;
var length = bend.Length;
if (length < etchLength * 3.0)
{
var pt1 = PointWorldToGraph(start);
var pt2 = PointWorldToGraph(end);
g.DrawLine(etchPen, pt1, pt2);
}
else
{
var angle = start.AngleTo(end);
var dx = System.Math.Cos(angle) * etchLength;
var dy = System.Math.Sin(angle) * etchLength;
var s1 = PointWorldToGraph(start);
var e1 = PointWorldToGraph(new Vector(start.X + dx, start.Y + dy));
g.DrawLine(etchPen, s1, e1);
var s2 = PointWorldToGraph(end);
var e2 = PointWorldToGraph(new Vector(end.X - dx, end.Y - dy));
g.DrawLine(etchPen, s2, e2);
}
}
}
private void DrawBendLines(Graphics g)
{
if (Bends == null || Bends.Count == 0)
+53
View File
@@ -584,6 +584,7 @@ namespace OpenNest.Controls
part.Draw(g, (i + 1).ToString());
DrawBendLines(g, part.BasePart);
DrawEtchMarks(g, part.BasePart);
DrawGrainWarning(g, part.BasePart);
}
@@ -657,6 +658,58 @@ namespace OpenNest.Controls
}
}
private void DrawEtchMarks(Graphics g, Part part)
{
if (!ShowBendLines || part.BaseDrawing.Bends == null || part.BaseDrawing.Bends.Count == 0)
return;
using var etchPen = new Pen(Color.Green, 1.5f);
var etchLength = 1.0;
foreach (var bend in part.BaseDrawing.Bends)
{
if (bend.Direction != BendDirection.Up)
continue;
var start = bend.StartPoint;
var end = bend.EndPoint;
// Apply part rotation
if (part.Rotation != 0)
{
start = start.Rotate(part.Rotation);
end = end.Rotate(part.Rotation);
}
// Apply part offset
start = start + part.Location;
end = end + part.Location;
var length = bend.Length;
var angle = bend.StartPoint.AngleTo(bend.EndPoint) + part.Rotation;
if (length < etchLength * 3.0)
{
var pt1 = PointWorldToGraph(start);
var pt2 = PointWorldToGraph(end);
g.DrawLine(etchPen, pt1, pt2);
}
else
{
var dx = System.Math.Cos(angle) * etchLength;
var dy = System.Math.Sin(angle) * etchLength;
var s1 = PointWorldToGraph(start);
var e1 = PointWorldToGraph(new Vector(start.X + dx, start.Y + dy));
g.DrawLine(etchPen, s1, e1);
var s2 = PointWorldToGraph(end);
var e2 = PointWorldToGraph(new Vector(end.X - dx, end.Y - dy));
g.DrawLine(etchPen, s2, e2);
}
}
}
private void DrawGrainWarning(Graphics g, Part part)
{
if (!ShowBendLines || Plate == null || part.BaseDrawing.Bends == null || part.BaseDrawing.Bends.Count == 0)
+400
View File
@@ -0,0 +1,400 @@
namespace OpenNest.Forms
{
partial class BomImportForm
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
components.Dispose();
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
grpInput = new System.Windows.Forms.GroupBox();
tbl = new System.Windows.Forms.TableLayoutPanel();
lblJobName = new System.Windows.Forms.Label();
txtJobName = new System.Windows.Forms.TextBox();
lblBomFile = new System.Windows.Forms.Label();
txtBomFile = new System.Windows.Forms.TextBox();
btnBrowseBom = new System.Windows.Forms.Button();
lblDxfFolder = new System.Windows.Forms.Label();
txtDxfFolder = new System.Windows.Forms.TextBox();
btnBrowseDxf = new System.Windows.Forms.Button();
lblPlateSize = new System.Windows.Forms.Label();
platePanel = new System.Windows.Forms.FlowLayoutPanel();
txtPlateWidth = new System.Windows.Forms.TextBox();
lblPlateX = new System.Windows.Forms.Label();
txtPlateLength = new System.Windows.Forms.TextBox();
btnAnalyze = new System.Windows.Forms.Button();
tabControl = new System.Windows.Forms.TabControl();
tabParts = new System.Windows.Forms.TabPage();
dgvParts = new System.Windows.Forms.DataGridView();
tabGroups = new System.Windows.Forms.TabPage();
dgvGroups = new System.Windows.Forms.DataGridView();
pnlBottom = new System.Windows.Forms.Panel();
lblSummary = new System.Windows.Forms.Label();
btnCreateNests = new System.Windows.Forms.Button();
btnClose = new System.Windows.Forms.Button();
grpInput.SuspendLayout();
tbl.SuspendLayout();
platePanel.SuspendLayout();
tabControl.SuspendLayout();
tabParts.SuspendLayout();
((System.ComponentModel.ISupportInitialize)dgvParts).BeginInit();
tabGroups.SuspendLayout();
((System.ComponentModel.ISupportInitialize)dgvGroups).BeginInit();
pnlBottom.SuspendLayout();
SuspendLayout();
//
// grpInput
//
grpInput.Controls.Add(tbl);
grpInput.Dock = System.Windows.Forms.DockStyle.Top;
grpInput.Location = new System.Drawing.Point(0, 0);
grpInput.Name = "grpInput";
grpInput.Padding = new System.Windows.Forms.Padding(6);
grpInput.Size = new System.Drawing.Size(804, 200);
grpInput.TabIndex = 0;
grpInput.TabStop = false;
grpInput.Text = "Input";
//
// tbl
//
tbl.ColumnCount = 3;
tbl.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
tbl.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
tbl.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
tbl.Controls.Add(lblJobName, 0, 0);
tbl.Controls.Add(txtJobName, 1, 0);
tbl.Controls.Add(lblBomFile, 0, 1);
tbl.Controls.Add(txtBomFile, 1, 1);
tbl.Controls.Add(btnBrowseBom, 2, 1);
tbl.Controls.Add(lblDxfFolder, 0, 2);
tbl.Controls.Add(txtDxfFolder, 1, 2);
tbl.Controls.Add(btnBrowseDxf, 2, 2);
tbl.Controls.Add(lblPlateSize, 0, 3);
tbl.Controls.Add(platePanel, 1, 3);
tbl.Controls.Add(btnAnalyze, 1, 4);
tbl.Dock = System.Windows.Forms.DockStyle.Fill;
tbl.Location = new System.Drawing.Point(6, 22);
tbl.Name = "tbl";
tbl.Padding = new System.Windows.Forms.Padding(3);
tbl.RowCount = 5;
tbl.RowStyles.Add(new System.Windows.Forms.RowStyle());
tbl.RowStyles.Add(new System.Windows.Forms.RowStyle());
tbl.RowStyles.Add(new System.Windows.Forms.RowStyle());
tbl.RowStyles.Add(new System.Windows.Forms.RowStyle());
tbl.RowStyles.Add(new System.Windows.Forms.RowStyle());
tbl.Size = new System.Drawing.Size(792, 172);
tbl.TabIndex = 0;
//
// lblJobName
//
lblJobName.Anchor = System.Windows.Forms.AnchorStyles.Left;
lblJobName.AutoSize = true;
lblJobName.Location = new System.Drawing.Point(6, 13);
lblJobName.Margin = new System.Windows.Forms.Padding(3, 6, 3, 3);
lblJobName.Name = "lblJobName";
lblJobName.Size = new System.Drawing.Size(63, 15);
lblJobName.TabIndex = 0;
lblJobName.Text = "Job Name:";
//
// txtJobName
//
tbl.SetColumnSpan(txtJobName, 2);
txtJobName.Dock = System.Windows.Forms.DockStyle.Fill;
txtJobName.Location = new System.Drawing.Point(79, 9);
txtJobName.Margin = new System.Windows.Forms.Padding(3, 6, 3, 3);
txtJobName.Name = "txtJobName";
txtJobName.Size = new System.Drawing.Size(707, 23);
txtJobName.TabIndex = 1;
//
// lblBomFile
//
lblBomFile.Anchor = System.Windows.Forms.AnchorStyles.Left;
lblBomFile.AutoSize = true;
lblBomFile.Location = new System.Drawing.Point(6, 45);
lblBomFile.Margin = new System.Windows.Forms.Padding(3, 6, 3, 3);
lblBomFile.Name = "lblBomFile";
lblBomFile.Size = new System.Drawing.Size(58, 15);
lblBomFile.TabIndex = 2;
lblBomFile.Text = "BOM File:";
//
// txtBomFile
//
txtBomFile.Dock = System.Windows.Forms.DockStyle.Fill;
txtBomFile.Location = new System.Drawing.Point(79, 41);
txtBomFile.Margin = new System.Windows.Forms.Padding(3, 6, 3, 3);
txtBomFile.Name = "txtBomFile";
txtBomFile.ReadOnly = true;
txtBomFile.Size = new System.Drawing.Size(669, 23);
txtBomFile.TabIndex = 3;
//
// btnBrowseBom
//
btnBrowseBom.Location = new System.Drawing.Point(751, 40);
btnBrowseBom.Margin = new System.Windows.Forms.Padding(0, 5, 3, 3);
btnBrowseBom.Name = "btnBrowseBom";
btnBrowseBom.Size = new System.Drawing.Size(35, 25);
btnBrowseBom.TabIndex = 4;
btnBrowseBom.Text = "...";
btnBrowseBom.Click += BrowseBom_Click;
//
// lblDxfFolder
//
lblDxfFolder.Anchor = System.Windows.Forms.AnchorStyles.Left;
lblDxfFolder.AutoSize = true;
lblDxfFolder.Location = new System.Drawing.Point(6, 78);
lblDxfFolder.Margin = new System.Windows.Forms.Padding(3, 6, 3, 3);
lblDxfFolder.Name = "lblDxfFolder";
lblDxfFolder.Size = new System.Drawing.Size(67, 15);
lblDxfFolder.TabIndex = 5;
lblDxfFolder.Text = "DXF Folder:";
//
// txtDxfFolder
//
txtDxfFolder.Dock = System.Windows.Forms.DockStyle.Fill;
txtDxfFolder.Location = new System.Drawing.Point(79, 74);
txtDxfFolder.Margin = new System.Windows.Forms.Padding(3, 6, 3, 3);
txtDxfFolder.Name = "txtDxfFolder";
txtDxfFolder.ReadOnly = true;
txtDxfFolder.Size = new System.Drawing.Size(669, 23);
txtDxfFolder.TabIndex = 6;
//
// btnBrowseDxf
//
btnBrowseDxf.Location = new System.Drawing.Point(751, 73);
btnBrowseDxf.Margin = new System.Windows.Forms.Padding(0, 5, 3, 3);
btnBrowseDxf.Name = "btnBrowseDxf";
btnBrowseDxf.Size = new System.Drawing.Size(35, 25);
btnBrowseDxf.TabIndex = 7;
btnBrowseDxf.Text = "...";
btnBrowseDxf.Click += BrowseDxf_Click;
//
// lblPlateSize
//
lblPlateSize.Anchor = System.Windows.Forms.AnchorStyles.Left;
lblPlateSize.AutoSize = true;
lblPlateSize.Location = new System.Drawing.Point(6, 112);
lblPlateSize.Margin = new System.Windows.Forms.Padding(3, 6, 3, 3);
lblPlateSize.Name = "lblPlateSize";
lblPlateSize.Size = new System.Drawing.Size(59, 15);
lblPlateSize.TabIndex = 8;
lblPlateSize.Text = "Plate Size:";
//
// platePanel
//
platePanel.AutoSize = true;
platePanel.Controls.Add(txtPlateWidth);
platePanel.Controls.Add(lblPlateX);
platePanel.Controls.Add(txtPlateLength);
platePanel.Location = new System.Drawing.Point(76, 104);
platePanel.Margin = new System.Windows.Forms.Padding(0, 3, 0, 3);
platePanel.Name = "platePanel";
platePanel.Size = new System.Drawing.Size(156, 29);
platePanel.TabIndex = 9;
platePanel.WrapContents = false;
//
// txtPlateWidth
//
txtPlateWidth.Location = new System.Drawing.Point(3, 3);
txtPlateWidth.Name = "txtPlateWidth";
txtPlateWidth.Size = new System.Drawing.Size(60, 23);
txtPlateWidth.TabIndex = 0;
txtPlateWidth.Text = "60";
//
// lblPlateX
//
lblPlateX.Anchor = System.Windows.Forms.AnchorStyles.Left;
lblPlateX.AutoSize = true;
lblPlateX.Location = new System.Drawing.Point(69, 7);
lblPlateX.Name = "lblPlateX";
lblPlateX.Size = new System.Drawing.Size(18, 15);
lblPlateX.TabIndex = 1;
lblPlateX.Text = " x ";
//
// txtPlateLength
//
txtPlateLength.Location = new System.Drawing.Point(93, 3);
txtPlateLength.Name = "txtPlateLength";
txtPlateLength.Size = new System.Drawing.Size(60, 23);
txtPlateLength.TabIndex = 2;
txtPlateLength.Text = "120";
//
// btnAnalyze
//
btnAnalyze.Anchor = System.Windows.Forms.AnchorStyles.Right;
tbl.SetColumnSpan(btnAnalyze, 2);
btnAnalyze.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
btnAnalyze.Location = new System.Drawing.Point(676, 142);
btnAnalyze.Margin = new System.Windows.Forms.Padding(3, 6, 3, 3);
btnAnalyze.Name = "btnAnalyze";
btnAnalyze.Size = new System.Drawing.Size(110, 30);
btnAnalyze.TabIndex = 10;
btnAnalyze.Text = "Analyze";
btnAnalyze.Click += Analyze_Click;
//
// tabControl
//
tabControl.Controls.Add(tabParts);
tabControl.Controls.Add(tabGroups);
tabControl.Dock = System.Windows.Forms.DockStyle.Fill;
tabControl.Location = new System.Drawing.Point(0, 200);
tabControl.Name = "tabControl";
tabControl.SelectedIndex = 0;
tabControl.Size = new System.Drawing.Size(804, 365);
tabControl.TabIndex = 1;
//
// tabParts
//
tabParts.Controls.Add(dgvParts);
tabParts.Name = "tabParts";
tabParts.Padding = new System.Windows.Forms.Padding(3);
tabParts.Text = "Parts";
//
// dgvParts
//
dgvParts.AllowUserToAddRows = false;
dgvParts.AllowUserToDeleteRows = false;
dgvParts.AutoSizeColumnsMode = System.Windows.Forms.DataGridViewAutoSizeColumnsMode.Fill;
dgvParts.BackgroundColor = System.Drawing.SystemColors.Window;
dgvParts.BorderStyle = System.Windows.Forms.BorderStyle.None;
dgvParts.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
dgvParts.Dock = System.Windows.Forms.DockStyle.Fill;
dgvParts.Name = "dgvParts";
dgvParts.RowHeadersVisible = false;
dgvParts.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
dgvParts.TabIndex = 0;
//
// tabGroups
//
tabGroups.Controls.Add(dgvGroups);
tabGroups.Name = "tabGroups";
tabGroups.Padding = new System.Windows.Forms.Padding(3);
tabGroups.Text = "Groups";
//
// dgvGroups
//
dgvGroups.AllowUserToAddRows = false;
dgvGroups.AllowUserToDeleteRows = false;
dgvGroups.AutoSizeColumnsMode = System.Windows.Forms.DataGridViewAutoSizeColumnsMode.Fill;
dgvGroups.BackgroundColor = System.Drawing.SystemColors.Window;
dgvGroups.BorderStyle = System.Windows.Forms.BorderStyle.None;
dgvGroups.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
dgvGroups.Dock = System.Windows.Forms.DockStyle.Fill;
dgvGroups.Name = "dgvGroups";
dgvGroups.RowHeadersVisible = false;
dgvGroups.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
dgvGroups.TabIndex = 0;
//
// pnlBottom
//
pnlBottom.Controls.Add(lblSummary);
pnlBottom.Controls.Add(btnCreateNests);
pnlBottom.Controls.Add(btnClose);
pnlBottom.Dock = System.Windows.Forms.DockStyle.Bottom;
pnlBottom.Location = new System.Drawing.Point(0, 565);
pnlBottom.Name = "pnlBottom";
pnlBottom.Padding = new System.Windows.Forms.Padding(10);
pnlBottom.Size = new System.Drawing.Size(804, 50);
pnlBottom.TabIndex = 2;
//
// lblSummary
//
lblSummary.AutoSize = true;
lblSummary.Dock = System.Windows.Forms.DockStyle.Left;
lblSummary.ForeColor = System.Drawing.Color.Gray;
lblSummary.Location = new System.Drawing.Point(10, 10);
lblSummary.Name = "lblSummary";
lblSummary.Size = new System.Drawing.Size(0, 15);
lblSummary.TabIndex = 0;
lblSummary.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// btnCreateNests
//
btnCreateNests.Dock = System.Windows.Forms.DockStyle.Right;
btnCreateNests.Enabled = false;
btnCreateNests.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
btnCreateNests.Location = new System.Drawing.Point(604, 10);
btnCreateNests.Margin = new System.Windows.Forms.Padding(0, 0, 6, 0);
btnCreateNests.Name = "btnCreateNests";
btnCreateNests.Size = new System.Drawing.Size(110, 30);
btnCreateNests.TabIndex = 1;
btnCreateNests.Text = "Create Nests";
btnCreateNests.Click += CreateNests_Click;
//
// btnClose
//
btnClose.DialogResult = System.Windows.Forms.DialogResult.Cancel;
btnClose.Dock = System.Windows.Forms.DockStyle.Right;
btnClose.Location = new System.Drawing.Point(714, 10);
btnClose.Name = "btnClose";
btnClose.Size = new System.Drawing.Size(80, 30);
btnClose.TabIndex = 2;
btnClose.Text = "Close";
btnClose.Click += BtnClose_Click;
//
// BomImportForm
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
CancelButton = btnClose;
ClientSize = new System.Drawing.Size(804, 615);
Controls.Add(tabControl);
Controls.Add(pnlBottom);
Controls.Add(grpInput);
Font = new System.Drawing.Font("Segoe UI", 9F);
MaximizeBox = false;
MinimumSize = new System.Drawing.Size(400, 350);
Name = "BomImportForm";
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
Text = "Import BOM";
grpInput.ResumeLayout(false);
tbl.ResumeLayout(false);
tbl.PerformLayout();
platePanel.ResumeLayout(false);
platePanel.PerformLayout();
tabParts.ResumeLayout(false);
((System.ComponentModel.ISupportInitialize)dgvParts).EndInit();
tabGroups.ResumeLayout(false);
((System.ComponentModel.ISupportInitialize)dgvGroups).EndInit();
tabControl.ResumeLayout(false);
pnlBottom.ResumeLayout(false);
pnlBottom.PerformLayout();
ResumeLayout(false);
}
#endregion
private System.Windows.Forms.GroupBox grpInput;
private System.Windows.Forms.TextBox txtJobName;
private System.Windows.Forms.TextBox txtBomFile;
private System.Windows.Forms.Button btnBrowseBom;
private System.Windows.Forms.TextBox txtDxfFolder;
private System.Windows.Forms.Button btnBrowseDxf;
private System.Windows.Forms.TextBox txtPlateWidth;
private System.Windows.Forms.TextBox txtPlateLength;
private System.Windows.Forms.Button btnAnalyze;
private System.Windows.Forms.TabControl tabControl;
private System.Windows.Forms.TabPage tabParts;
private System.Windows.Forms.DataGridView dgvParts;
private System.Windows.Forms.TabPage tabGroups;
private System.Windows.Forms.DataGridView dgvGroups;
private System.Windows.Forms.Panel pnlBottom;
private System.Windows.Forms.Label lblSummary;
private System.Windows.Forms.Button btnCreateNests;
private System.Windows.Forms.Button btnClose;
private System.Windows.Forms.TableLayoutPanel tbl;
private System.Windows.Forms.Label lblJobName;
private System.Windows.Forms.Label lblBomFile;
private System.Windows.Forms.Label lblDxfFolder;
private System.Windows.Forms.Label lblPlateSize;
private System.Windows.Forms.FlowLayoutPanel platePanel;
private System.Windows.Forms.Label lblPlateX;
}
}
+490
View File
@@ -0,0 +1,490 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.IO.Bom;
using System;
using System.Collections.Generic;
using System.Data;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Windows.Forms;
namespace OpenNest.Forms
{
public partial class BomImportForm : Form
{
private List<BomPartRow> _parts;
private Dictionary<string, (double Width, double Length)> _plateSizes;
private bool _suppressRegroup;
public Form MdiParentForm { get; set; }
public BomImportForm()
{
InitializeComponent();
_parts = new List<BomPartRow>();
_plateSizes = new Dictionary<string, (double, double)>();
}
#region File Browsing
private void BrowseBom_Click(object sender, EventArgs e)
{
using var dlg = new OpenFileDialog
{
Title = "Select BOM File",
Filter = "Excel Files|*.xlsx|All Files|*.*",
FilterIndex = 1,
};
if (dlg.ShowDialog(this) != DialogResult.OK)
return;
txtBomFile.Text = dlg.FileName;
if (string.IsNullOrWhiteSpace(txtDxfFolder.Text))
txtDxfFolder.Text = Path.GetDirectoryName(dlg.FileName);
if (string.IsNullOrWhiteSpace(txtJobName.Text))
{
var name = Path.GetFileNameWithoutExtension(dlg.FileName);
if (name.EndsWith(" BOM", StringComparison.OrdinalIgnoreCase))
name = name.Substring(0, name.Length - 4).TrimEnd();
txtJobName.Text = name;
}
}
private void BrowseDxf_Click(object sender, EventArgs e)
{
using var dlg = new FolderBrowserDialog
{
Description = "Select DXF Folder",
SelectedPath = txtDxfFolder.Text,
};
if (dlg.ShowDialog(this) != DialogResult.OK)
return;
txtDxfFolder.Text = dlg.SelectedPath;
}
#endregion
#region Analyze
private void Analyze_Click(object sender, EventArgs e)
{
if (!File.Exists(txtBomFile.Text))
{
MessageBox.Show("BOM file does not exist.", "Validation Error",
MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
try
{
List<BomItem> items;
using (var reader = new BomReader(txtBomFile.Text))
items = reader.GetItems();
var analysis = BomAnalyzer.Analyze(items, txtDxfFolder.Text);
BuildPartRows(items, analysis);
PopulatePartsGrid();
RebuildGroups();
UpdateSummary();
btnCreateNests.Enabled = true;
tabControl.SelectedTab = tabParts;
}
catch (Exception ex)
{
MessageBox.Show($"Error reading BOM: {ex.Message}", "Error",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void BuildPartRows(List<BomItem> items, BomAnalysis analysis)
{
var matchedPaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in analysis.Groups)
foreach (var part in group.Parts)
if (part.DxfPath != null)
matchedPaths[part.Item.FileName ?? ""] = part.DxfPath;
_parts = new List<BomPartRow>();
foreach (var item in items)
{
var row = new BomPartRow
{
ItemNum = item.ItemNum,
FileName = item.FileName,
Qty = item.Qty,
Description = item.Description,
Material = item.Material,
Thickness = item.Thickness,
};
if (string.IsNullOrWhiteSpace(item.FileName))
{
row.Status = "Skipped";
row.IsEditable = false;
}
else
{
var lookupName = item.FileName;
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (matchedPaths.TryGetValue(lookupName, out var dxfPath))
{
row.DxfPath = dxfPath;
row.Status = "Matched";
row.IsEditable = true;
}
else
{
row.Status = "No DXF";
row.IsEditable = false;
}
}
_parts.Add(row);
}
_plateSizes.Clear();
}
#endregion
#region Parts Tab
private void PopulatePartsGrid()
{
_suppressRegroup = true;
var table = new DataTable();
table.Columns.Add("Item #", typeof(string));
table.Columns.Add("File Name", typeof(string));
table.Columns.Add("Qty", typeof(string));
table.Columns.Add("Description", typeof(string));
table.Columns.Add("Material", typeof(string));
table.Columns.Add("Thickness", typeof(string));
table.Columns.Add("Status", typeof(string));
foreach (var part in _parts)
{
table.Rows.Add(
part.ItemNum?.ToString() ?? "",
part.FileName ?? "",
part.Qty?.ToString() ?? "",
part.Description ?? "",
part.Material ?? "",
part.Thickness?.ToString("0.####") ?? "",
part.Status
);
}
dgvParts.DataSource = table;
// Make non-editable columns read-only
foreach (DataGridViewColumn col in dgvParts.Columns)
{
if (col.Name != "Material" && col.Name != "Thickness")
col.ReadOnly = true;
}
// Style rows by status
for (var i = 0; i < _parts.Count; i++)
{
if (!_parts[i].IsEditable)
{
dgvParts.Rows[i].ReadOnly = true;
dgvParts.Rows[i].DefaultCellStyle.ForeColor = Color.Gray;
}
}
dgvParts.CellValueChanged -= DgvParts_CellValueChanged;
dgvParts.CellValueChanged += DgvParts_CellValueChanged;
_suppressRegroup = false;
}
private void DgvParts_CellValueChanged(object sender, DataGridViewCellEventArgs e)
{
if (_suppressRegroup || e.RowIndex < 0)
return;
var colName = dgvParts.Columns[e.ColumnIndex].Name;
if (colName != "Material" && colName != "Thickness")
return;
var part = _parts[e.RowIndex];
if (!part.IsEditable)
return;
if (colName == "Material")
part.Material = dgvParts.Rows[e.RowIndex].Cells[e.ColumnIndex].Value?.ToString();
if (colName == "Thickness")
{
var text = dgvParts.Rows[e.RowIndex].Cells[e.ColumnIndex].Value?.ToString();
part.Thickness = double.TryParse(text, out var t) ? t : (double?)null;
}
RebuildGroups();
UpdateSummary();
}
#endregion
#region Groups Tab
private void RebuildGroups()
{
// Save existing plate sizes before rebuilding
SavePlateSizes();
var defaultWidth = double.TryParse(txtPlateWidth.Text, out var w) ? w : 60;
var defaultLength = double.TryParse(txtPlateLength.Text, out var l) ? l : 120;
var groups = _parts
.Where(p => p.IsEditable
&& !string.IsNullOrWhiteSpace(p.Material)
&& p.Thickness.HasValue)
.GroupBy(p => new
{
Material = p.Material.ToUpperInvariant(),
Thickness = p.Thickness.Value
})
.OrderBy(g => g.First().Material)
.ThenBy(g => g.Key.Thickness)
.ToList();
var table = new DataTable();
table.Columns.Add("Material", typeof(string));
table.Columns.Add("Thickness", typeof(double));
table.Columns.Add("Parts", typeof(int));
table.Columns.Add("Total Qty", typeof(int));
table.Columns.Add("Plate Width", typeof(double));
table.Columns.Add("Plate Length", typeof(double));
foreach (var group in groups)
{
var material = group.First().Material;
var thickness = group.Key.Thickness;
var key = GroupKey(material, thickness);
var plateWidth = _plateSizes.TryGetValue(key, out var size) ? size.Width : defaultWidth;
var plateLength = _plateSizes.TryGetValue(key, out _) ? size.Length : defaultLength;
table.Rows.Add(
material,
thickness,
group.Count(),
group.Sum(p => p.Qty ?? 0),
plateWidth,
plateLength
);
}
dgvGroups.DataSource = table;
// Material, Thickness, Parts, Total Qty are read-only
if (dgvGroups.Columns.Count >= 6)
{
dgvGroups.Columns["Material"].ReadOnly = true;
dgvGroups.Columns["Thickness"].ReadOnly = true;
dgvGroups.Columns["Parts"].ReadOnly = true;
dgvGroups.Columns["Total Qty"].ReadOnly = true;
}
btnCreateNests.Enabled = table.Rows.Count > 0;
}
private void SavePlateSizes()
{
if (dgvGroups.DataSource is not DataTable table)
return;
_plateSizes.Clear();
foreach (DataRow row in table.Rows)
{
var material = row["Material"]?.ToString() ?? "";
var thickness = row["Thickness"] is double t ? t : 0;
var key = GroupKey(material, thickness);
var width = row["Plate Width"] is double pw ? pw : 60;
var length = row["Plate Length"] is double pl ? pl : 120;
_plateSizes[key] = (width, length);
}
}
private static string GroupKey(string material, double thickness)
=> $"{material?.ToUpperInvariant()}|{thickness}";
#endregion
#region Summary
private void UpdateSummary()
{
var skipped = _parts.Count(p => p.Status == "Skipped");
var noDxf = _parts.Count(p => p.Status == "No DXF");
var matched = _parts.Count(p => p.Status == "Matched");
var summaryParts = new List<string>();
if (skipped > 0)
summaryParts.Add($"{skipped} skipped (no file name)");
if (noDxf > 0)
summaryParts.Add($"{noDxf} no DXF found");
lblSummary.Text = summaryParts.Count > 0
? string.Join(", ", summaryParts)
: $"{matched} parts matched";
}
#endregion
#region Create Nests
private void CreateNests_Click(object sender, EventArgs e)
{
if (_parts == null || _parts.Count == 0)
return;
// Save latest plate size edits
SavePlateSizes();
var defaultWidth = double.TryParse(txtPlateWidth.Text, out var dw) ? dw : 60;
var defaultLength = double.TryParse(txtPlateLength.Text, out var dl) ? dl : 120;
var groups = _parts
.Where(p => p.IsEditable
&& !string.IsNullOrWhiteSpace(p.Material)
&& p.Thickness.HasValue
&& !string.IsNullOrWhiteSpace(p.DxfPath))
.GroupBy(p => new
{
Material = p.Material.ToUpperInvariant(),
Thickness = p.Thickness.Value
})
.ToList();
if (groups.Count == 0)
{
MessageBox.Show("No groups with matched DXF files to create nests from.", "Nothing to Create",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var jobName = txtJobName.Text.Trim();
var importer = new DxfImporter();
var nestsCreated = 0;
var importErrors = new List<string>();
foreach (var group in groups)
{
var material = group.First().Material;
var thickness = group.Key.Thickness;
var key = GroupKey(material, thickness);
var plateWidth = _plateSizes.TryGetValue(key, out var size) ? size.Width : defaultWidth;
var plateLength = _plateSizes.TryGetValue(key, out _) ? size.Length : defaultLength;
var nestName = $"{jobName} - {thickness:0.###} {material}";
var nest = new Nest(nestName);
nest.DateCreated = DateTime.Now;
nest.DateLastModified = DateTime.Now;
nest.PlateDefaults.Size = new Geometry.Size(plateWidth, plateLength);
nest.PlateDefaults.Thickness = thickness;
nest.PlateDefaults.Material = new Material(material);
nest.PlateDefaults.Quadrant = 1;
nest.PlateDefaults.PartSpacing = 1;
nest.PlateDefaults.EdgeSpacing = new Spacing(1, 1, 1, 1);
foreach (var part in group)
{
if (!File.Exists(part.DxfPath))
{
importErrors.Add($"{part.FileName}: DXF file not found");
continue;
}
try
{
var result = importer.Import(part.DxfPath);
var drawingName = Path.GetFileNameWithoutExtension(part.DxfPath);
var drawing = new Drawing(drawingName);
drawing.Source.Path = part.DxfPath;
drawing.Quantity.Required = part.Qty ?? 1;
drawing.Material = new Material(material);
var normalized = ShapeProfile.NormalizeEntities(result.Entities);
var pgm = ConvertGeometry.ToProgram(normalized);
if (pgm.Codes.Count > 0 && pgm[0].Type == CodeType.RapidMove)
{
var rapid = (RapidMove)pgm[0];
drawing.Source.Offset = rapid.EndPoint;
pgm.Offset(-rapid.EndPoint);
pgm.Codes.RemoveAt(0);
}
drawing.Program = pgm;
nest.Drawings.Add(drawing);
}
catch (Exception ex)
{
importErrors.Add($"{part.FileName}: {ex.Message}");
}
}
if (nest.Drawings.Count == 0)
continue;
nest.CreatePlate();
var editForm = new EditNestForm(nest);
editForm.MdiParent = MdiParentForm;
editForm.Show();
editForm.PlateView.ZoomToFit();
nestsCreated++;
}
var summary = $"{nestsCreated} nest{(nestsCreated != 1 ? "s" : "")} created.";
if (importErrors.Count > 0)
summary += $"\n\n{importErrors.Count} import error(s):\n" + string.Join("\n", importErrors);
MessageBox.Show(summary, "Import Complete", MessageBoxButtons.OK,
importErrors.Count > 0 ? MessageBoxIcon.Warning : MessageBoxIcon.Information);
Close();
}
#endregion
private void BtnClose_Click(object sender, EventArgs e)
{
Close();
}
}
internal class BomPartRow
{
public int? ItemNum { get; set; }
public string FileName { get; set; }
public int? Qty { get; set; }
public string Description { get; set; }
public string Material { get; set; }
public double? Thickness { get; set; }
public string DxfPath { get; set; }
public string Status { get; set; }
public bool IsEditable { get; set; }
}
}
+12 -31
View File
@@ -81,6 +81,8 @@ namespace OpenNest.Forms
?? new List<Bend>();
}
Bend.UpdateEtchEntities(result.Entities, bends);
var item = new FileListItem
{
Name = Path.GetFileNameWithoutExtension(file),
@@ -245,6 +247,9 @@ namespace OpenNest.Forms
bend.SourceEntity.IsVisible = true;
item.Bends.RemoveAt(index);
Bend.UpdateEtchEntities(item.Entities, item.Bends);
entityView1.Entities.Clear();
entityView1.Entities.AddRange(item.Entities);
entityView1.Bends = item.Bends;
entityView1.SelectedBendIndex = -1;
filterPanel.LoadItem(item.Entities, item.Bends);
@@ -281,16 +286,8 @@ namespace OpenNest.Forms
var entities = item.Entities.Where(en => en.Layer.IsVisible && en.IsVisible).ToList();
if (entities.Count == 0) return;
var shape = new ShapeProfile(entities);
SetRotation(shape.Perimeter, RotationType.CW);
foreach (var cutout in shape.Cutouts)
SetRotation(cutout, RotationType.CCW);
var drawEntities = new List<Entity>();
drawEntities.AddRange(shape.Perimeter.Entities);
shape.Cutouts.ForEach(c => drawEntities.AddRange(c.Entities));
var pgm = ConvertGeometry.ToProgram(drawEntities);
var normalized = ShapeProfile.NormalizeEntities(entities);
var pgm = ConvertGeometry.ToProgram(normalized);
var originOffset = Vector.Zero;
if (pgm.Codes.Count > 0 && pgm[0].Type == CodeType.RapidMove)
{
@@ -395,6 +392,9 @@ namespace OpenNest.Forms
line.IsVisible = false;
item.Bends.Add(bend);
Bend.UpdateEtchEntities(item.Entities, item.Bends);
entityView1.Entities.Clear();
entityView1.Entities.AddRange(item.Entities);
entityView1.Bends = item.Bends;
filterPanel.LoadItem(item.Entities, item.Bends);
entityView1.Invalidate();
@@ -560,18 +560,8 @@ namespace OpenNest.Forms
if (item.Bends != null)
drawing.Bends.AddRange(item.Bends);
var shape = new ShapeProfile(entities);
SetRotation(shape.Perimeter, RotationType.CW);
foreach (var cutout in shape.Cutouts)
SetRotation(cutout, RotationType.CCW);
entities = new List<Entity>();
entities.AddRange(shape.Perimeter.Entities);
shape.Cutouts.ForEach(cutout => entities.AddRange(cutout.Entities));
var pgm = ConvertGeometry.ToProgram(entities);
var normalized = ShapeProfile.NormalizeEntities(entities);
var pgm = ConvertGeometry.ToProgram(normalized);
var firstCode = pgm[0];
if (firstCode.Type == CodeType.RapidMove)
@@ -605,15 +595,6 @@ namespace OpenNest.Forms
}
}
private static void SetRotation(Shape shape, RotationType rotation)
{
try
{
var dir = shape.ToPolygon(3).RotationDirection();
if (dir != rotation) shape.Reverse();
}
catch { }
}
private static Color GetNextColor()
{
+419
View File
@@ -0,0 +1,419 @@
using OpenNest.Data;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows.Forms;
namespace OpenNest.Forms
{
public class MachineConfigForm : Form
{
private readonly IDataProvider _provider;
private readonly TreeView _tree;
private readonly Panel _detailPanel;
private MachineConfig _currentMachine;
public MachineConfigForm(IDataProvider provider)
{
_provider = provider;
Text = "Machine Configuration";
Size = new Size(900, 600);
StartPosition = FormStartPosition.CenterParent;
MinimumSize = new Size(700, 400);
var splitContainer = new SplitContainer
{
Dock = DockStyle.Fill,
SplitterDistance = 250,
FixedPanel = FixedPanel.Panel1
};
_tree = new TreeView
{
Dock = DockStyle.Fill,
HideSelection = false
};
_tree.AfterSelect += Tree_AfterSelect;
var treeButtonPanel = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
AutoSize = true,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = true,
Padding = new Padding(2)
};
var addMachineButton = new Button { Text = "+ Machine", AutoSize = true };
addMachineButton.Click += AddMachine_Click;
var removeMachineButton = new Button { Text = "- Machine", AutoSize = true };
removeMachineButton.Click += RemoveMachine_Click;
var addMaterialButton = new Button { Text = "+ Material", AutoSize = true };
addMaterialButton.Click += AddMaterial_Click;
var removeMaterialButton = new Button { Text = "- Material", AutoSize = true };
removeMaterialButton.Click += RemoveMaterial_Click;
var addThicknessButton = new Button { Text = "+ Thickness", AutoSize = true };
addThicknessButton.Click += AddThickness_Click;
var removeThicknessButton = new Button { Text = "- Thickness", AutoSize = true };
removeThicknessButton.Click += RemoveThickness_Click;
treeButtonPanel.Controls.AddRange(new Control[]
{
addMachineButton, removeMachineButton,
addMaterialButton, removeMaterialButton,
addThicknessButton, removeThicknessButton
});
splitContainer.Panel1.Controls.Add(_tree);
splitContainer.Panel1.Controls.Add(treeButtonPanel);
_detailPanel = new Panel { Dock = DockStyle.Fill, AutoScroll = true };
splitContainer.Panel2.Controls.Add(_detailPanel);
var bottomPanel = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
AutoSize = true,
FlowDirection = FlowDirection.RightToLeft,
Padding = new Padding(4)
};
var saveButton = new Button { Text = "Save", AutoSize = true };
saveButton.Click += Save_Click;
var importButton = new Button { Text = "Import...", AutoSize = true };
importButton.Click += Import_Click;
var exportButton = new Button { Text = "Export...", AutoSize = true };
exportButton.Click += Export_Click;
bottomPanel.Controls.AddRange(new Control[] { saveButton, exportButton, importButton });
Controls.Add(splitContainer);
Controls.Add(bottomPanel);
LoadTree();
}
private void LoadTree()
{
_tree.Nodes.Clear();
foreach (var summary in _provider.GetMachines())
{
var machine = _provider.GetMachine(summary.Id);
if (machine is null) continue;
var machineNode = new TreeNode(machine.Name) { Tag = machine };
foreach (var material in machine.Materials)
{
var matNode = new TreeNode(material.Name) { Tag = material };
foreach (var thickness in material.Thicknesses)
{
var thickNode = new TreeNode(thickness.Value.ToString("0.####")) { Tag = thickness };
matNode.Nodes.Add(thickNode);
}
machineNode.Nodes.Add(matNode);
}
_tree.Nodes.Add(machineNode);
}
if (_tree.Nodes.Count > 0)
_tree.SelectedNode = _tree.Nodes[0];
}
private void Tree_AfterSelect(object sender, TreeViewEventArgs e)
{
_detailPanel.Controls.Clear();
if (e.Node?.Tag is null) return;
switch (e.Node.Tag)
{
case MachineConfig machine:
_currentMachine = machine;
ShowMachineDetails(machine);
break;
case MaterialConfig material:
_currentMachine = e.Node.Parent?.Tag as MachineConfig;
ShowMaterialDetails(material);
break;
case ThicknessConfig thickness:
_currentMachine = e.Node.Parent?.Parent?.Tag as MachineConfig;
ShowThicknessDetails(thickness);
break;
}
}
private void ShowMachineDetails(MachineConfig machine)
{
var layout = CreateDetailLayout();
var row = 0;
AddField(layout, ref row, "Name:", CreateTextBox(machine.Name, v => machine.Name = v));
AddField(layout, ref row, "Type:", CreateEnumCombo(machine.Type, v => machine.Type = v));
AddField(layout, ref row, "Units:", CreateEnumCombo(machine.Units, v => machine.Units = v));
_detailPanel.Controls.Add(layout);
}
private void ShowMaterialDetails(MaterialConfig material)
{
var layout = CreateDetailLayout();
var row = 0;
AddField(layout, ref row, "Name:", CreateTextBox(material.Name, v => material.Name = v));
AddField(layout, ref row, "Grade:", CreateTextBox(material.Grade, v => material.Grade = v));
AddField(layout, ref row, "Density:", CreateNumericBox(material.Density, v => material.Density = v, 4));
_detailPanel.Controls.Add(layout);
}
private void ShowThicknessDetails(ThicknessConfig thickness)
{
var layout = CreateDetailLayout();
var row = 0;
AddField(layout, ref row, "Thickness:", CreateNumericBox(thickness.Value, v => thickness.Value = v, 4));
AddField(layout, ref row, "Kerf:", CreateNumericBox(thickness.Kerf, v => thickness.Kerf = v, 4));
AddField(layout, ref row, "Assist Gas:", CreateTextBox(thickness.AssistGas, v => thickness.AssistGas = v));
AddSectionHeader(layout, ref row, "Lead In");
AddField(layout, ref row, "Type:", CreateTextBox(thickness.LeadIn.Type, v => thickness.LeadIn.Type = v));
AddField(layout, ref row, "Length:", CreateNumericBox(thickness.LeadIn.Length, v => thickness.LeadIn.Length = v, 4));
AddField(layout, ref row, "Angle:", CreateNumericBox(thickness.LeadIn.Angle, v => thickness.LeadIn.Angle = v, 1));
AddField(layout, ref row, "Radius:", CreateNumericBox(thickness.LeadIn.Radius, v => thickness.LeadIn.Radius = v, 4));
AddSectionHeader(layout, ref row, "Lead Out");
AddField(layout, ref row, "Type:", CreateTextBox(thickness.LeadOut.Type, v => thickness.LeadOut.Type = v));
AddField(layout, ref row, "Length:", CreateNumericBox(thickness.LeadOut.Length, v => thickness.LeadOut.Length = v, 4));
AddField(layout, ref row, "Angle:", CreateNumericBox(thickness.LeadOut.Angle, v => thickness.LeadOut.Angle = v, 1));
AddField(layout, ref row, "Radius:", CreateNumericBox(thickness.LeadOut.Radius, v => thickness.LeadOut.Radius = v, 4));
AddSectionHeader(layout, ref row, "Cut Off");
AddField(layout, ref row, "Part Clearance:", CreateNumericBox(thickness.CutOff.PartClearance, v => thickness.CutOff.PartClearance = v, 4));
AddField(layout, ref row, "Overtravel:", CreateNumericBox(thickness.CutOff.Overtravel, v => thickness.CutOff.Overtravel = v, 4));
AddField(layout, ref row, "Min Segment:", CreateNumericBox(thickness.CutOff.MinSegmentLength, v => thickness.CutOff.MinSegmentLength = v, 4));
AddField(layout, ref row, "Direction:", CreateTextBox(thickness.CutOff.Direction, v => thickness.CutOff.Direction = v));
AddSectionHeader(layout, ref row, "Plate Sizes");
var sizesText = string.Join(", ", thickness.PlateSizes);
AddField(layout, ref row, "Sizes:", CreateTextBox(sizesText, v =>
{
thickness.PlateSizes = v.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}));
_detailPanel.Controls.Add(layout);
}
private static TableLayoutPanel CreateDetailLayout()
{
var layout = new TableLayoutPanel
{
Dock = DockStyle.Top,
AutoSize = true,
ColumnCount = 2,
Padding = new Padding(8)
};
layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
return layout;
}
private static void AddField(TableLayoutPanel layout, ref int row, string label, Control control)
{
layout.RowCount = row + 1;
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.Controls.Add(new Label { Text = label, AutoSize = true, Anchor = AnchorStyles.Left, Margin = new Padding(0, 6, 8, 0) }, 0, row);
control.Dock = DockStyle.Fill;
layout.Controls.Add(control, 1, row);
row++;
}
private static void AddSectionHeader(TableLayoutPanel layout, ref int row, string text)
{
layout.RowCount = row + 1;
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
var label = new Label
{
Text = text,
AutoSize = true,
Font = new Font(Control.DefaultFont, FontStyle.Bold),
Margin = new Padding(0, 12, 0, 4)
};
layout.SetColumnSpan(label, 2);
layout.Controls.Add(label, 0, row);
row++;
}
private static TextBox CreateTextBox(string value, Action<string> setter)
{
var textBox = new TextBox { Text = value };
textBox.TextChanged += (s, e) => setter(textBox.Text);
return textBox;
}
private static NumericUpDown CreateNumericBox(double value, Action<double> setter, int decimals)
{
var numeric = new NumericUpDown
{
DecimalPlaces = decimals,
Minimum = 0,
Maximum = 10000,
Increment = (decimal)System.Math.Pow(10, -decimals),
Value = (decimal)value
};
numeric.ValueChanged += (s, e) => setter((double)numeric.Value);
return numeric;
}
private static ComboBox CreateEnumCombo<T>(T currentValue, Action<T> setter) where T : struct, Enum
{
var combo = new ComboBox
{
DropDownStyle = ComboBoxStyle.DropDownList
};
combo.Items.AddRange(Enum.GetNames<T>().Cast<object>().ToArray());
combo.SelectedItem = currentValue.ToString();
combo.SelectedIndexChanged += (s, e) =>
{
if (Enum.TryParse<T>(combo.SelectedItem?.ToString(), out var val))
setter(val);
};
return combo;
}
private void Save_Click(object sender, EventArgs e)
{
foreach (TreeNode machineNode in _tree.Nodes)
{
if (machineNode.Tag is MachineConfig machine)
_provider.SaveMachine(machine);
}
MessageBox.Show("Machine configurations saved.", "Saved", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
private void AddMachine_Click(object sender, EventArgs e)
{
var machine = new MachineConfig { Name = "New Machine" };
_provider.SaveMachine(machine);
LoadTree();
}
private void RemoveMachine_Click(object sender, EventArgs e)
{
if (_tree.SelectedNode?.Tag is not MachineConfig machine) return;
if (MessageBox.Show($"Delete machine '{machine.Name}'?", "Confirm", MessageBoxButtons.YesNo) != DialogResult.Yes) return;
_provider.DeleteMachine(machine.Id);
LoadTree();
}
private void AddMaterial_Click(object sender, EventArgs e)
{
if (_currentMachine is null) return;
_currentMachine.Materials.Add(new MaterialConfig { Name = "New Material" });
_provider.SaveMachine(_currentMachine);
LoadTree();
}
private void RemoveMaterial_Click(object sender, EventArgs e)
{
if (_tree.SelectedNode?.Tag is not MaterialConfig material) return;
if (_currentMachine is null) return;
_currentMachine.Materials.Remove(material);
_provider.SaveMachine(_currentMachine);
LoadTree();
}
private void AddThickness_Click(object sender, EventArgs e)
{
var material = _tree.SelectedNode?.Tag as MaterialConfig;
if (material is null && _tree.SelectedNode?.Tag is ThicknessConfig)
material = _tree.SelectedNode.Parent?.Tag as MaterialConfig;
if (material is null || _currentMachine is null) return;
material.Thicknesses.Add(new ThicknessConfig { Value = 0.250 });
_provider.SaveMachine(_currentMachine);
LoadTree();
}
private void RemoveThickness_Click(object sender, EventArgs e)
{
if (_tree.SelectedNode?.Tag is not ThicknessConfig thickness) return;
var material = _tree.SelectedNode.Parent?.Tag as MaterialConfig;
if (material is null || _currentMachine is null) return;
material.Thicknesses.Remove(thickness);
_provider.SaveMachine(_currentMachine);
LoadTree();
}
private void Import_Click(object sender, EventArgs e)
{
using (var dialog = new OpenFileDialog
{
Filter = "JSON files (*.json)|*.json",
Title = "Import Machine Configuration"
})
{
if (dialog.ShowDialog() != DialogResult.OK) return;
try
{
var json = File.ReadAllText(dialog.FileName);
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
var machine = JsonSerializer.Deserialize<MachineConfig>(json, options);
if (machine is null) return;
machine.Id = Guid.NewGuid();
_provider.SaveMachine(machine);
LoadTree();
}
catch (Exception ex)
{
MessageBox.Show($"Failed to import: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
private void Export_Click(object sender, EventArgs e)
{
if (_currentMachine is null) return;
using (var dialog = new SaveFileDialog
{
Filter = "JSON files (*.json)|*.json",
FileName = $"{_currentMachine.Name}.json",
Title = "Export Machine Configuration"
})
{
if (dialog.ShowDialog() != DialogResult.OK) return;
try
{
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
var json = JsonSerializer.Serialize(_currentMachine, options);
File.WriteAllText(dialog.FileName, json);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to export: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
}
}
+23 -5
View File
@@ -32,6 +32,7 @@
mnuFile = new System.Windows.Forms.ToolStripMenuItem();
mnuFileNew = new System.Windows.Forms.ToolStripMenuItem();
mnuFileOpen = new System.Windows.Forms.ToolStripMenuItem();
mnuFileImportBom = new System.Windows.Forms.ToolStripMenuItem();
toolStripMenuItem1 = new System.Windows.Forms.ToolStripSeparator();
mnuFileSave = new System.Windows.Forms.ToolStripMenuItem();
mnuFileSaveAs = new System.Windows.Forms.ToolStripMenuItem();
@@ -78,6 +79,7 @@
mnuSetOffsetIncrement = new System.Windows.Forms.ToolStripMenuItem();
mnuSetRotationIncrement = new System.Windows.Forms.ToolStripMenuItem();
toolStripMenuItem15 = new System.Windows.Forms.ToolStripSeparator();
mnuToolsMachineConfig = new System.Windows.Forms.ToolStripMenuItem();
mnuToolsOptions = new System.Windows.Forms.ToolStripMenuItem();
mnuNest = new System.Windows.Forms.ToolStripMenuItem();
mnuNestEdit = new System.Windows.Forms.ToolStripMenuItem();
@@ -171,7 +173,7 @@
//
// mnuFile
//
mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, toolStripMenuItem3, mnuFileExit });
mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, mnuFileImportBom, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, toolStripMenuItem3, mnuFileExit });
mnuFile.Name = "mnuFile";
mnuFile.Size = new System.Drawing.Size(37, 20);
mnuFile.Text = "&File";
@@ -186,14 +188,21 @@
mnuFileNew.Click += New_Click;
//
// mnuFileOpen
//
//
mnuFileOpen.Image = Properties.Resources.doc_open;
mnuFileOpen.Name = "mnuFileOpen";
mnuFileOpen.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.O;
mnuFileOpen.Size = new System.Drawing.Size(146, 22);
mnuFileOpen.Text = "Open";
mnuFileOpen.Click += Open_Click;
//
//
// mnuFileImportBom
//
mnuFileImportBom.Name = "mnuFileImportBom";
mnuFileImportBom.Size = new System.Drawing.Size(180, 22);
mnuFileImportBom.Text = "Import BOM...";
mnuFileImportBom.Click += ImportBom_Click;
//
// toolStripMenuItem1
//
toolStripMenuItem1.Name = "toolStripMenuItem1";
@@ -395,7 +404,7 @@
//
// mnuTools
//
mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsMeasureArea, mnuToolsBestFitViewer, mnuToolsPatternTile, mnuToolsAlign, toolStripMenuItem14, mnuSetOffsetIncrement, mnuSetRotationIncrement, toolStripMenuItem15, mnuToolsOptions });
mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsMeasureArea, mnuToolsBestFitViewer, mnuToolsPatternTile, mnuToolsAlign, toolStripMenuItem14, mnuSetOffsetIncrement, mnuSetRotationIncrement, toolStripMenuItem15, mnuToolsMachineConfig, mnuToolsOptions });
mnuTools.Name = "mnuTools";
mnuTools.Size = new System.Drawing.Size(47, 20);
mnuTools.Text = "&Tools";
@@ -520,8 +529,15 @@
toolStripMenuItem15.Name = "toolStripMenuItem15";
toolStripMenuItem15.Size = new System.Drawing.Size(211, 6);
//
// mnuToolsMachineConfig
//
mnuToolsMachineConfig.Name = "mnuToolsMachineConfig";
mnuToolsMachineConfig.Size = new System.Drawing.Size(214, 22);
mnuToolsMachineConfig.Text = "Machine Configuration...";
mnuToolsMachineConfig.Click += MachineConfig_Click;
//
// mnuToolsOptions
//
//
mnuToolsOptions.Name = "mnuToolsOptions";
mnuToolsOptions.Size = new System.Drawing.Size(214, 22);
mnuToolsOptions.Text = "Options";
@@ -1125,6 +1141,7 @@
private System.Windows.Forms.ToolStripMenuItem mnuFile;
private System.Windows.Forms.ToolStripMenuItem mnuFileNew;
private System.Windows.Forms.ToolStripMenuItem mnuFileOpen;
private System.Windows.Forms.ToolStripMenuItem mnuFileImportBom;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem1;
private System.Windows.Forms.ToolStripMenuItem mnuFileSave;
private System.Windows.Forms.ToolStripMenuItem mnuFileSaveAs;
@@ -1145,6 +1162,7 @@
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawOffset;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem5;
private System.Windows.Forms.ToolStripMenuItem mnuTools;
private System.Windows.Forms.ToolStripMenuItem mnuToolsMachineConfig;
private System.Windows.Forms.ToolStripMenuItem mnuToolsOptions;
private System.Windows.Forms.ToolStripMenuItem mnuNest;
private System.Windows.Forms.ToolStripMenuItem mnuNestEdit;
+19
View File
@@ -1,5 +1,6 @@
using OpenNest.Actions;
using OpenNest.Collections;
using OpenNest.Data;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
@@ -473,6 +474,13 @@ namespace OpenNest.Forms
}
}
private void ImportBom_Click(object sender, EventArgs e)
{
var form = new BomImportForm();
form.MdiParentForm = this;
form.ShowDialog(this);
}
private void Save_Click(object sender, EventArgs e)
{
if (activeForm != null)
@@ -732,6 +740,17 @@ namespace OpenNest.Forms
form.ShowDialog();
}
private void MachineConfig_Click(object sender, EventArgs e)
{
var appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenNest", "Machines");
var provider = new LocalJsonProvider(appDataPath);
provider.EnsureDefaults();
using (var form = new MachineConfigForm(provider))
{
form.ShowDialog(this);
}
}
private void AlignLeft_Click(object sender, EventArgs e)
{
if (activeForm == null) return;
+24 -20
View File
@@ -178,32 +178,36 @@ namespace OpenNest
{
var result = new List<PointF[]>();
var entities = ConvertProgram.ToGeometry(BasePart.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var profile = new ShapeProfile(
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
foreach (var shape in shapes)
{
var offsetEntity = shape.OffsetOutward(spacing);
AddOffsetPolygon(result, profile.Perimeter.OffsetOutward(spacing), tolerance);
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(tolerance);
polygon.RemoveSelfIntersections();
if (polygon.Vertices.Count < 2)
continue;
var pts = new PointF[polygon.Vertices.Count];
for (var j = 0; j < pts.Length; j++)
pts[j] = new PointF((float)polygon.Vertices[j].X, (float)polygon.Vertices[j].Y);
result.Add(pts);
}
foreach (var cutout in profile.Cutouts)
AddOffsetPolygon(result, cutout.OffsetInward(spacing), tolerance);
return result;
}
private static void AddOffsetPolygon(List<PointF[]> result, Shape offsetEntity, double tolerance)
{
if (offsetEntity == null)
return;
var polygon = offsetEntity.ToPolygonWithTolerance(tolerance);
polygon.RemoveSelfIntersections();
if (polygon.Vertices.Count < 2)
return;
var pts = new PointF[polygon.Vertices.Count];
for (var j = 0; j < pts.Length; j++)
pts[j] = new PointF((float)polygon.Vertices[j].X, (float)polygon.Vertices[j].Y);
result.Add(pts);
}
private void RebuildOffsetPath(Matrix matrix)
{
OffsetPath?.Dispose();
+1
View File
@@ -13,6 +13,7 @@
<ItemGroup>
<ProjectReference Include="..\OpenNest.Api\OpenNest.Api.csproj" />
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Data\OpenNest.Data.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />