Compare commits
9 Commits
1945270fa7
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e493d83899 | |||
| 987a5e25bc | |||
| 86582d28c3 | |||
| f064368008 | |||
| 9148797897 | |||
| da77cc9270 | |||
| 27f0685058 | |||
| 53988acefc | |||
| a8d90be2ea |
@@ -213,3 +213,6 @@ docs/superpowers/
|
|||||||
|
|
||||||
# Launch settings
|
# Launch settings
|
||||||
**/Properties/launchSettings.json
|
**/Properties/launchSettings.json
|
||||||
|
|
||||||
|
# Local test config (contains user-specific paths to proprietary test assets)
|
||||||
|
OpenNest.Tests/test-config.json
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace OpenNest.Engine.BestFit
|
namespace OpenNest.Engine.BestFit
|
||||||
{
|
{
|
||||||
@@ -54,6 +57,68 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
return new List<Part> { part1, part2 };
|
return new List<Part> { part1, part2 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Part> BuildCanonicalParts()
|
||||||
|
{
|
||||||
|
return NormalizeToCutOrigin(BuildParts(Candidate.Drawing));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Part> BuildSourceParts(Drawing drawing)
|
||||||
|
{
|
||||||
|
var parts = BuildCanonicalParts();
|
||||||
|
var sourceAngle = drawing?.Source?.Angle ?? 0.0;
|
||||||
|
|
||||||
|
for (var i = 0; i < parts.Count; i++)
|
||||||
|
{
|
||||||
|
var p = parts[i];
|
||||||
|
var rebound = Part.CreateAtOrigin(drawing, p.Rotation);
|
||||||
|
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
|
||||||
|
rebound.Offset(delta);
|
||||||
|
rebound.UpdateBounds();
|
||||||
|
parts[i] = rebound;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NormalizeToCutOrigin(CanonicalFrame.FromCanonical(parts, sourceAngle));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Box GetCutBounds(List<Part> parts)
|
||||||
|
{
|
||||||
|
return GetCutBoundingBox(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Part> NormalizeToCutOrigin(List<Part> parts)
|
||||||
|
{
|
||||||
|
if (parts == null || parts.Count == 0)
|
||||||
|
return parts;
|
||||||
|
|
||||||
|
var bounds = GetCutBoundingBox(parts);
|
||||||
|
var offset = new Vector(-bounds.Left, -bounds.Bottom);
|
||||||
|
|
||||||
|
foreach (var part in parts)
|
||||||
|
part.Offset(offset);
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Box GetCutBoundingBox(List<Part> parts)
|
||||||
|
{
|
||||||
|
var entities = new List<IBoundable>();
|
||||||
|
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
var partEntities = ConvertProgram.ToGeometry(part.Program)
|
||||||
|
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var entity in partEntities)
|
||||||
|
{
|
||||||
|
entity.Offset(part.Location);
|
||||||
|
entities.Add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities.GetBoundingBox();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum BestFitSortField
|
public enum BestFitSortField
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.Engine.Fill
|
namespace OpenNest.Engine.Fill
|
||||||
{
|
{
|
||||||
@@ -14,7 +15,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
|
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
|
||||||
{
|
{
|
||||||
var obstacleParts = plate.Parts
|
var obstacleParts = plate.Parts
|
||||||
.Where(p => !movingParts.Contains(p))
|
.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
||||||
@@ -26,7 +27,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
public static double Push(List<Part> movingParts, Plate plate, double angle)
|
public static double Push(List<Part> movingParts, Plate plate, double angle)
|
||||||
{
|
{
|
||||||
var obstacleParts = plate.Parts
|
var obstacleParts = plate.Parts
|
||||||
.Where(p => !movingParts.Contains(p))
|
.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
|
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
|
||||||
@@ -99,6 +100,13 @@ namespace OpenNest.Engine.Fill
|
|||||||
: PartGeometry.GetPerimeterEntities(obstacleParts[i]);
|
: PartGeometry.GetPerimeterEntities(obstacleParts[i]);
|
||||||
|
|
||||||
var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction);
|
var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction);
|
||||||
|
if (d <= Tolerance.Epsilon
|
||||||
|
&& partSpacing <= Tolerance.Epsilon
|
||||||
|
&& CanNudgeWithoutOverlap(moving, obstacleParts[i], direction))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (d < distance)
|
if (d < distance)
|
||||||
distance = d;
|
distance = d;
|
||||||
}
|
}
|
||||||
@@ -115,6 +123,31 @@ namespace OpenNest.Engine.Fill
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IntersectsAny(Part candidate, List<Part> parts)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < parts.Count; i++)
|
||||||
|
{
|
||||||
|
if (candidate.Intersects(parts[i], out _))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanNudgeWithoutOverlap(Part moving, Part obstacle, Vector direction)
|
||||||
|
{
|
||||||
|
var nudge = direction * (Tolerance.Epsilon * 10);
|
||||||
|
|
||||||
|
moving.Offset(nudge);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return !moving.Intersects(obstacle, out _);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
moving.Offset(-nudge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static double Push(List<Part> movingParts, List<Part> obstacleParts,
|
public static double Push(List<Part> movingParts, List<Part> obstacleParts,
|
||||||
Box workArea, double partSpacing, PushDirection direction)
|
Box workArea, double partSpacing, PushDirection direction)
|
||||||
{
|
{
|
||||||
@@ -130,7 +163,7 @@ namespace OpenNest.Engine.Fill
|
|||||||
public static double PushBoundingBox(List<Part> movingParts, Plate plate, PushDirection direction)
|
public static double PushBoundingBox(List<Part> movingParts, Plate plate, PushDirection direction)
|
||||||
{
|
{
|
||||||
var obstacleParts = plate.Parts
|
var obstacleParts = plate.Parts
|
||||||
.Where(p => !movingParts.Contains(p))
|
.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ namespace OpenNest.IO
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool DetectBends { get; set; } = true;
|
public bool DetectBends { get; set; } = true;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// When true, detects and identifies title block entities during import. Default true.
|
|
||||||
/// </summary>
|
|
||||||
public bool DetectTitleBlock { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Override the drawing name. Null = filename without extension.
|
/// Override the drawing name. Null = filename without extension.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -45,10 +45,5 @@ namespace OpenNest.IO
|
|||||||
/// that need access to non-geometry entities (e.g., text annotations).
|
/// that need access to non-geometry entities (e.g., text annotations).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public CadDocument Document { get; set; }
|
public CadDocument Document { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// GUIDs of entities identified as part of the title block during import.
|
|
||||||
/// </summary>
|
|
||||||
public HashSet<System.Guid> TitleBlockEntityIds { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ namespace OpenNest.IO
|
|||||||
|
|
||||||
Bend.UpdateEtchEntities(dxf.Entities, bends);
|
Bend.UpdateEtchEntities(dxf.Entities, bends);
|
||||||
|
|
||||||
HashSet<System.Guid> titleBlockIds = null;
|
|
||||||
if (options.DetectTitleBlock)
|
|
||||||
titleBlockIds = TitleBlockDetector.Detect(dxf.Entities, dxf.Document);
|
|
||||||
|
|
||||||
return new CadImportResult
|
return new CadImportResult
|
||||||
{
|
{
|
||||||
Entities = dxf.Entities,
|
Entities = dxf.Entities,
|
||||||
@@ -53,7 +49,6 @@ namespace OpenNest.IO
|
|||||||
SourcePath = path,
|
SourcePath = path,
|
||||||
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
|
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
|
||||||
Document = dxf.Document,
|
Document = dxf.Document,
|
||||||
TitleBlockEntityIds = titleBlockIds,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,16 +139,6 @@ namespace OpenNest.IO
|
|||||||
.Where(e => !(e.Layer != null && e.Layer.IsVisible && e.IsVisible))
|
.Where(e => !(e.Layer != null && e.Layer.IsVisible && e.IsVisible))
|
||||||
.Select(e => e.Id));
|
.Select(e => e.Id));
|
||||||
|
|
||||||
if (result.TitleBlockEntityIds != null)
|
|
||||||
{
|
|
||||||
var sourceIds = new HashSet<System.Guid>(drawing.SourceEntities.Select(e => e.Id));
|
|
||||||
foreach (var id in result.TitleBlockEntityIds)
|
|
||||||
{
|
|
||||||
if (sourceIds.Contains(id))
|
|
||||||
drawing.SuppressedEntityIds.Add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return drawing;
|
return drawing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.IO
|
||||||
|
{
|
||||||
|
public class ChrFont
|
||||||
|
{
|
||||||
|
private readonly Dictionary<int, ChrGlyph> glyphs = new();
|
||||||
|
|
||||||
|
public string Name { get; internal set; }
|
||||||
|
public string Version { get; internal set; }
|
||||||
|
public double CapHeight { get; internal set; } = 5000;
|
||||||
|
|
||||||
|
internal void AddGlyph(int charCode, ChrGlyph glyph)
|
||||||
|
{
|
||||||
|
glyphs[charCode] = glyph;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasGlyph(int charCode) => glyphs.ContainsKey(charCode);
|
||||||
|
|
||||||
|
public ChrGlyph GetGlyph(int charCode) =>
|
||||||
|
glyphs.TryGetValue(charCode, out var g) ? g : null;
|
||||||
|
|
||||||
|
public double MeasureTextWidth(string text, double height)
|
||||||
|
{
|
||||||
|
var scale = height / CapHeight;
|
||||||
|
double width = 0;
|
||||||
|
foreach (var ch in text)
|
||||||
|
{
|
||||||
|
var glyph = GetGlyph(ch);
|
||||||
|
if (glyph == null)
|
||||||
|
{
|
||||||
|
var space = GetGlyph(' ');
|
||||||
|
width += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
width += glyph.AdvanceWidth * scale;
|
||||||
|
}
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Entity> RenderText(string text, double height, Vector position, Layer layer = null)
|
||||||
|
{
|
||||||
|
var scale = height / CapHeight;
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
var cursorX = position.X;
|
||||||
|
|
||||||
|
foreach (var ch in text)
|
||||||
|
{
|
||||||
|
var glyph = GetGlyph(ch);
|
||||||
|
if (glyph == null)
|
||||||
|
{
|
||||||
|
var space = GetGlyph(' ');
|
||||||
|
cursorX += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var glyphEntities = glyph.ToEntities(scale, cursorX, position.Y, layer);
|
||||||
|
entities.AddRange(glyphEntities);
|
||||||
|
cursorX += glyph.AdvanceWidth * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChrFont Read(string path, byte? xorKey = null)
|
||||||
|
{
|
||||||
|
var raw = File.ReadAllBytes(path);
|
||||||
|
|
||||||
|
// The whole file is obfuscated with a single-byte XOR. Different
|
||||||
|
// GravoStyle versions use different keys (0x2F in older releases,
|
||||||
|
// 0xCF in 7000-series). The font name at offset 0 is ASCII stored
|
||||||
|
// as UTF-16LE, so the high byte of its first character is 0x00 in
|
||||||
|
// plaintext — which means raw[1] is exactly the XOR key. Detect it
|
||||||
|
// from the file unless the caller forces a specific key.
|
||||||
|
var key = xorKey ?? (raw.Length > 1 ? raw[1] : (byte)0x2F);
|
||||||
|
|
||||||
|
var data = new byte[raw.Length];
|
||||||
|
for (var i = 0; i < raw.Length; i++)
|
||||||
|
data[i] = (byte)(raw[i] ^ key);
|
||||||
|
|
||||||
|
return Parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChrFont Parse(byte[] data)
|
||||||
|
{
|
||||||
|
var font = new ChrFont();
|
||||||
|
|
||||||
|
font.Name = Encoding.Unicode.GetString(data, 0, 26).TrimEnd('\0').Trim();
|
||||||
|
font.Version = Encoding.ASCII.GetString(data, 26, 12).TrimEnd('\0').Trim();
|
||||||
|
|
||||||
|
var charTable = new List<(int charCode, int offset)>();
|
||||||
|
var i = 0x40;
|
||||||
|
while (i + 5 < data.Length)
|
||||||
|
{
|
||||||
|
var charCode = data[i] | (data[i + 1] << 8);
|
||||||
|
var offset = data[i + 2] | (data[i + 3] << 8) | (data[i + 4] << 16) | (data[i + 5] << 24);
|
||||||
|
|
||||||
|
if (charCode < 0x20 || offset == 0 || offset >= data.Length)
|
||||||
|
break;
|
||||||
|
|
||||||
|
charTable.Add((charCode, offset));
|
||||||
|
i += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var c = 0; c < charTable.Count; c++)
|
||||||
|
{
|
||||||
|
var (charCode, offset) = charTable[c];
|
||||||
|
|
||||||
|
var nextOffset = c + 1 < charTable.Count
|
||||||
|
? FindNextOffset(charTable, offset, data.Length)
|
||||||
|
: data.Length;
|
||||||
|
|
||||||
|
var glyph = ParseGlyph(data, offset, nextOffset);
|
||||||
|
if (glyph != null)
|
||||||
|
font.AddGlyph(charCode, glyph);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (font.glyphs.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var g in font.glyphs.Values)
|
||||||
|
{
|
||||||
|
if (g.CapHeight > 0)
|
||||||
|
{
|
||||||
|
font.CapHeight = g.CapHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return font;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FindNextOffset(List<(int charCode, int offset)> table, int currentOffset, int fileLength)
|
||||||
|
{
|
||||||
|
var best = fileLength;
|
||||||
|
foreach (var (_, off) in table)
|
||||||
|
{
|
||||||
|
if (off > currentOffset && off < best)
|
||||||
|
best = off;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChrGlyph ParseGlyph(byte[] data, int offset, int endOffset)
|
||||||
|
{
|
||||||
|
if (offset + 92 > data.Length)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var glyph = new ChrGlyph();
|
||||||
|
glyph.CapHeight = ReadBE16(data, offset + 15 * 2);
|
||||||
|
var bearing = System.Math.Abs(ReadBE16(data, offset + 18 * 2));
|
||||||
|
glyph.AdvanceWidth = ReadBE16(data, offset + 22 * 2) + bearing;
|
||||||
|
|
||||||
|
var strokeStart = offset + 92;
|
||||||
|
var pos = strokeStart;
|
||||||
|
var currentStroke = new List<ChrStrokePoint>();
|
||||||
|
|
||||||
|
while (pos + 5 < endOffset)
|
||||||
|
{
|
||||||
|
var cmd = ReadBE16(data, pos);
|
||||||
|
var x = ReadBE16(data, pos + 2);
|
||||||
|
var y = ReadBE16(data, pos + 4);
|
||||||
|
pos += 6;
|
||||||
|
|
||||||
|
if (System.Math.Abs(x) > 15000 || System.Math.Abs(y) > 15000)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (cmd < -1000)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var type = cmd switch
|
||||||
|
{
|
||||||
|
1 => ChrPointType.Vertex,
|
||||||
|
4 => ChrPointType.Control,
|
||||||
|
5 => ChrPointType.EndPoint,
|
||||||
|
_ => ChrPointType.Vertex,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentStroke.Add(new ChrStrokePoint(type, x, y));
|
||||||
|
|
||||||
|
if (type == ChrPointType.EndPoint)
|
||||||
|
{
|
||||||
|
if (currentStroke.Count > 0)
|
||||||
|
glyph.Strokes.Add(currentStroke);
|
||||||
|
currentStroke = new List<ChrStrokePoint>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStroke.Count > 0)
|
||||||
|
glyph.Strokes.Add(currentStroke);
|
||||||
|
|
||||||
|
return glyph;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadBE16(byte[] data, int offset)
|
||||||
|
{
|
||||||
|
var val = (data[offset] << 8) | data[offset + 1];
|
||||||
|
if (val > 32767) val -= 65536;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum ChrPointType
|
||||||
|
{
|
||||||
|
Vertex,
|
||||||
|
Control,
|
||||||
|
EndPoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal struct ChrStrokePoint
|
||||||
|
{
|
||||||
|
public ChrPointType Type;
|
||||||
|
public double X;
|
||||||
|
public double Y;
|
||||||
|
|
||||||
|
public ChrStrokePoint(ChrPointType type, double x, double y)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
X = x;
|
||||||
|
Y = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChrGlyph
|
||||||
|
{
|
||||||
|
internal readonly List<List<ChrStrokePoint>> Strokes = new();
|
||||||
|
|
||||||
|
public double AdvanceWidth { get; internal set; }
|
||||||
|
public double CapHeight { get; internal set; }
|
||||||
|
|
||||||
|
private const int ArcSamples = 16;
|
||||||
|
|
||||||
|
public List<Entity> ToEntities(double scale, double offsetX, double offsetY, Layer layer = null)
|
||||||
|
{
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
layer ??= Layer.Default;
|
||||||
|
|
||||||
|
foreach (var stroke in Strokes)
|
||||||
|
{
|
||||||
|
if (stroke.Count < 2) continue;
|
||||||
|
|
||||||
|
var segments = BuildSegments(stroke);
|
||||||
|
foreach (var seg in segments)
|
||||||
|
{
|
||||||
|
if (seg.Points.Count < 2) continue;
|
||||||
|
|
||||||
|
var scaled = new List<Vector>(seg.Points.Count);
|
||||||
|
foreach (var pt in seg.Points)
|
||||||
|
scaled.Add(new Vector(pt.X * scale + offsetX, pt.Y * scale + offsetY));
|
||||||
|
|
||||||
|
var converted = PointsToLines(scaled);
|
||||||
|
|
||||||
|
foreach (var e in converted)
|
||||||
|
{
|
||||||
|
e.Layer = layer;
|
||||||
|
entities.Add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Entity> PointsToLines(List<Vector> points)
|
||||||
|
{
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
for (var i = 0; i < points.Count - 1; i++)
|
||||||
|
{
|
||||||
|
if (points[i].DistanceTo(points[i + 1]) < 0.001)
|
||||||
|
continue;
|
||||||
|
entities.Add(new Line(points[i], points[i + 1]));
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<StrokeSegment> BuildSegments(List<ChrStrokePoint> stroke)
|
||||||
|
{
|
||||||
|
var segments = new List<StrokeSegment>();
|
||||||
|
var current = new StrokeSegment();
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
while (i < stroke.Count)
|
||||||
|
{
|
||||||
|
var pt = stroke[i];
|
||||||
|
|
||||||
|
if (pt.Type == ChrPointType.Vertex || pt.Type == ChrPointType.EndPoint)
|
||||||
|
{
|
||||||
|
if (i + 1 < stroke.Count && stroke[i + 1].Type == ChrPointType.Control)
|
||||||
|
{
|
||||||
|
var p0 = new Vector(pt.X, pt.Y);
|
||||||
|
var pMid = new Vector(stroke[i + 1].X, stroke[i + 1].Y);
|
||||||
|
var p2End = i + 2 < stroke.Count ? stroke[i + 2] : stroke[i + 1];
|
||||||
|
var p1 = new Vector(p2End.X, p2End.Y);
|
||||||
|
|
||||||
|
SampleCircularArc(current.Points, p0, pMid, p1, ArcSamples);
|
||||||
|
current.HasCurves = true;
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current.Points.Add(new Vector(pt.X, pt.Y));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Points.Count >= 2)
|
||||||
|
segments.Add(current);
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class StrokeSegment
|
||||||
|
{
|
||||||
|
public readonly List<Vector> Points = new();
|
||||||
|
public bool HasCurves;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SampleCircularArc(List<Vector> output, Vector p0, Vector pMid, Vector p1, int samples)
|
||||||
|
{
|
||||||
|
if (output.Count == 0 || output[^1].DistanceTo(p0) > 0.01)
|
||||||
|
output.Add(p0);
|
||||||
|
|
||||||
|
double ax = p0.X, ay = p0.Y;
|
||||||
|
double bx = pMid.X, by = pMid.Y;
|
||||||
|
double cx = p1.X, cy = p1.Y;
|
||||||
|
|
||||||
|
var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
||||||
|
|
||||||
|
if (System.Math.Abs(d) < 1e-6)
|
||||||
|
{
|
||||||
|
output.Add(pMid);
|
||||||
|
output.Add(p1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
|
||||||
|
var uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
|
||||||
|
var radius = System.Math.Sqrt((ax - ux) * (ax - ux) + (ay - uy) * (ay - uy));
|
||||||
|
|
||||||
|
var a0 = System.Math.Atan2(ay - uy, ax - ux);
|
||||||
|
var am = System.Math.Atan2(by - uy, bx - ux);
|
||||||
|
var a1 = System.Math.Atan2(cy - uy, cx - ux);
|
||||||
|
|
||||||
|
var ccwSweep = a1 - a0;
|
||||||
|
while (ccwSweep <= 0) ccwSweep += 2 * System.Math.PI;
|
||||||
|
|
||||||
|
var midRel = am - a0;
|
||||||
|
while (midRel < 0) midRel += 2 * System.Math.PI;
|
||||||
|
|
||||||
|
var sweep = midRel < ccwSweep ? ccwSweep : ccwSweep - 2 * System.Math.PI;
|
||||||
|
|
||||||
|
for (var i = 1; i <= samples; i++)
|
||||||
|
{
|
||||||
|
var t = (double)i / samples;
|
||||||
|
var angle = a0 + sweep * t;
|
||||||
|
output.Add(new Vector(ux + radius * System.Math.Cos(angle), uy + radius * System.Math.Sin(angle)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-2
@@ -65,6 +65,36 @@ namespace OpenNest.IO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static List<Entity> GetGeometry(string path, Func<string, bool> layerFilter)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new DxfReader(path);
|
||||||
|
var doc = reader.Read();
|
||||||
|
return ConvertEntities(doc, layerFilter);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine(ex.Message);
|
||||||
|
return new List<Entity>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Entity> GetGeometry(Stream stream, Func<string, bool> layerFilter)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new DxfReader(stream);
|
||||||
|
var doc = reader.Read();
|
||||||
|
return ConvertEntities(doc, layerFilter);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Debug.WriteLine(ex.Message);
|
||||||
|
return new List<Entity>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Export
|
#region Export
|
||||||
@@ -128,16 +158,17 @@ namespace OpenNest.IO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Entity> ConvertEntities(CadDocument doc)
|
private static List<Entity> ConvertEntities(CadDocument doc, Func<string, bool> layerFilter = null)
|
||||||
{
|
{
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
var lines = new List<Line>();
|
var lines = new List<Line>();
|
||||||
var arcs = new List<Arc>();
|
var arcs = new List<Arc>();
|
||||||
var circles = new List<Circle>();
|
var circles = new List<Circle>();
|
||||||
|
var filter = layerFilter ?? IsNonCutLayer;
|
||||||
|
|
||||||
foreach (var entity in doc.Entities)
|
foreach (var entity in doc.Entities)
|
||||||
{
|
{
|
||||||
if (IsNonCutLayer(entity.Layer?.Name))
|
if (filter(entity.Layer?.Name))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
switch (entity)
|
switch (entity)
|
||||||
|
|||||||
@@ -1,312 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using ACadSharp;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
|
|
||||||
namespace OpenNest.IO
|
|
||||||
{
|
|
||||||
public static class TitleBlockDetector
|
|
||||||
{
|
|
||||||
private static readonly HashSet<string> TitleBlockLayerNames = new(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
"TITLE", "TITLEBLOCK", "TITLE_BLOCK", "BORDER", "FRAME",
|
|
||||||
"TB", "INFO", "SHEET", "ANNOTATION"
|
|
||||||
};
|
|
||||||
|
|
||||||
public static HashSet<Guid> Detect(List<Entity> entities, CadDocument document)
|
|
||||||
{
|
|
||||||
var flagged = new HashSet<Guid>();
|
|
||||||
DetectByLayerName(entities, flagged);
|
|
||||||
DetectBorder(entities, flagged);
|
|
||||||
if (document != null)
|
|
||||||
DetectTitleBlockRegion(entities, document, flagged);
|
|
||||||
return flagged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void DetectByLayerName(List<Entity> entities, HashSet<Guid> flagged)
|
|
||||||
{
|
|
||||||
foreach (var entity in entities)
|
|
||||||
{
|
|
||||||
if (entity.Layer?.Name != null && TitleBlockLayerNames.Contains(entity.Layer.Name))
|
|
||||||
flagged.Add(entity.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void DetectBorder(List<Entity> entities, HashSet<Guid> flagged)
|
|
||||||
{
|
|
||||||
var lines = entities.OfType<Line>().Where(l => !flagged.Contains(l.Id)).ToList();
|
|
||||||
if (lines.Count < 4) return;
|
|
||||||
|
|
||||||
var bounds = entities.GetBoundingBox();
|
|
||||||
if (bounds == null || bounds.Area() < OpenNest.Math.Tolerance.Epsilon) return;
|
|
||||||
|
|
||||||
var borderCount = 0;
|
|
||||||
foreach (var line in lines)
|
|
||||||
{
|
|
||||||
if (IsBorderLine(line, bounds))
|
|
||||||
{
|
|
||||||
flagged.Add(line.Id);
|
|
||||||
borderCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (borderCount >= 2)
|
|
||||||
DetectZoneMarkers(lines, bounds, flagged);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsBorderLine(Line line, Box bounds)
|
|
||||||
{
|
|
||||||
var dx = line.EndPoint.X - line.StartPoint.X;
|
|
||||||
var dy = line.EndPoint.Y - line.StartPoint.Y;
|
|
||||||
var length = System.Math.Sqrt(dx * dx + dy * dy);
|
|
||||||
var angleRad = System.Math.Atan2(System.Math.Abs(dy), System.Math.Abs(dx));
|
|
||||||
var angularTolerance = OpenNest.Math.Angle.ToRadians(2.0);
|
|
||||||
var positionTolerance = System.Math.Max(bounds.Length, bounds.Width) * 0.01;
|
|
||||||
|
|
||||||
var isHorizontal = angleRad < angularTolerance;
|
|
||||||
var isVertical = System.Math.Abs(angleRad - System.Math.PI / 2) < angularTolerance;
|
|
||||||
|
|
||||||
if (!isHorizontal && !isVertical) return false;
|
|
||||||
|
|
||||||
var minSpan = isHorizontal ? bounds.Length * 0.8 : bounds.Width * 0.8;
|
|
||||||
if (length < minSpan) return false;
|
|
||||||
|
|
||||||
if (isHorizontal)
|
|
||||||
{
|
|
||||||
var midY = (line.StartPoint.Y + line.EndPoint.Y) / 2;
|
|
||||||
return System.Math.Abs(midY - bounds.Bottom) < positionTolerance
|
|
||||||
|| System.Math.Abs(midY - bounds.Top) < positionTolerance;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var midX = (line.StartPoint.X + line.EndPoint.X) / 2;
|
|
||||||
return System.Math.Abs(midX - bounds.Left) < positionTolerance
|
|
||||||
|| System.Math.Abs(midX - bounds.Right) < positionTolerance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void DetectZoneMarkers(List<Line> lines, Box bounds, HashSet<Guid> flagged)
|
|
||||||
{
|
|
||||||
var positionTolerance = System.Math.Max(bounds.Length, bounds.Width) * 0.01;
|
|
||||||
var maxTickLength = System.Math.Max(bounds.Length, bounds.Width) * 0.05;
|
|
||||||
var angularTolerance = OpenNest.Math.Angle.ToRadians(2.0);
|
|
||||||
|
|
||||||
foreach (var line in lines)
|
|
||||||
{
|
|
||||||
if (flagged.Contains(line.Id)) continue;
|
|
||||||
|
|
||||||
var dx = line.EndPoint.X - line.StartPoint.X;
|
|
||||||
var dy = line.EndPoint.Y - line.StartPoint.Y;
|
|
||||||
var length = System.Math.Sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (length > maxTickLength || length < OpenNest.Math.Tolerance.Epsilon) continue;
|
|
||||||
|
|
||||||
var angleRad = System.Math.Atan2(System.Math.Abs(dy), System.Math.Abs(dx));
|
|
||||||
var isVertical = System.Math.Abs(angleRad - System.Math.PI / 2) < angularTolerance;
|
|
||||||
var isHorizontal = angleRad < angularTolerance;
|
|
||||||
|
|
||||||
if (!isVertical && !isHorizontal) continue;
|
|
||||||
|
|
||||||
var touchesEdge = false;
|
|
||||||
if (isVertical)
|
|
||||||
{
|
|
||||||
var minY = System.Math.Min(line.StartPoint.Y, line.EndPoint.Y);
|
|
||||||
var maxY = System.Math.Max(line.StartPoint.Y, line.EndPoint.Y);
|
|
||||||
touchesEdge = System.Math.Abs(minY - bounds.Bottom) < positionTolerance
|
|
||||||
|| System.Math.Abs(maxY - bounds.Top) < positionTolerance;
|
|
||||||
}
|
|
||||||
else if (isHorizontal)
|
|
||||||
{
|
|
||||||
var minX = System.Math.Min(line.StartPoint.X, line.EndPoint.X);
|
|
||||||
var maxX = System.Math.Max(line.StartPoint.X, line.EndPoint.X);
|
|
||||||
touchesEdge = System.Math.Abs(minX - bounds.Left) < positionTolerance
|
|
||||||
|| System.Math.Abs(maxX - bounds.Right) < positionTolerance;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (touchesEdge)
|
|
||||||
flagged.Add(line.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void DetectTitleBlockRegion(List<Entity> entities, CadDocument document, HashSet<Guid> flagged)
|
|
||||||
{
|
|
||||||
var textPositions = ExtractTextPositions(document);
|
|
||||||
if (textPositions.Count < 3) return;
|
|
||||||
|
|
||||||
var unflagged = entities.Where(e => !flagged.Contains(e.Id)).ToList();
|
|
||||||
if (unflagged.Count == 0) return;
|
|
||||||
|
|
||||||
var bounds = entities.GetBoundingBox();
|
|
||||||
if (bounds == null || bounds.Area() < OpenNest.Math.Tolerance.Epsilon) return;
|
|
||||||
|
|
||||||
var bestRegion = FindBestTitleBlockRegion(bounds, textPositions, unflagged);
|
|
||||||
if (bestRegion == null) return;
|
|
||||||
|
|
||||||
var initiallyInside = unflagged.Where(e => {
|
|
||||||
var c = EntityCenter(e);
|
|
||||||
return c.HasValue && RegionContains(bestRegion, c.Value);
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
var expandedBounds = initiallyInside.Count > 0 ? initiallyInside.GetBoundingBox() : null;
|
|
||||||
|
|
||||||
foreach (var entity in unflagged)
|
|
||||||
{
|
|
||||||
var center = EntityCenter(entity);
|
|
||||||
if (!center.HasValue) continue;
|
|
||||||
if (RegionContains(bestRegion, center.Value)
|
|
||||||
|| (expandedBounds != null && RegionContains(expandedBounds, center.Value)))
|
|
||||||
flagged.Add(entity.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Vector> ExtractTextPositions(CadDocument document)
|
|
||||||
{
|
|
||||||
var positions = new List<Vector>();
|
|
||||||
foreach (var entity in document.Entities)
|
|
||||||
{
|
|
||||||
switch (entity)
|
|
||||||
{
|
|
||||||
case ACadSharp.Entities.MText mtext:
|
|
||||||
positions.Add(new Vector(mtext.InsertPoint.X, mtext.InsertPoint.Y));
|
|
||||||
break;
|
|
||||||
case ACadSharp.Entities.TextEntity text:
|
|
||||||
var pt = text.HorizontalAlignment != 0 || text.VerticalAlignment != 0
|
|
||||||
? text.AlignmentPoint : text.InsertPoint;
|
|
||||||
positions.Add(new Vector(pt.X, pt.Y));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return positions;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Box FindBestTitleBlockRegion(Box bounds, List<Vector> textPositions, List<Entity> entities)
|
|
||||||
{
|
|
||||||
var candidates = GenerateCandidateRegions(bounds);
|
|
||||||
Box bestRegion = null;
|
|
||||||
var bestScore = 0.0;
|
|
||||||
|
|
||||||
var openLines = FindOpenLines(entities);
|
|
||||||
|
|
||||||
foreach (var region in candidates)
|
|
||||||
{
|
|
||||||
var textCount = textPositions.Count(p => RegionContains(region, p));
|
|
||||||
if (textCount < 3) continue;
|
|
||||||
|
|
||||||
var openLineCount = openLines.Count(l => RegionContains(region, l.MidPoint));
|
|
||||||
|
|
||||||
var area = region.Area();
|
|
||||||
if (area < OpenNest.Math.Tolerance.Epsilon) continue;
|
|
||||||
|
|
||||||
var score = (double)textCount + openLineCount * 0.5;
|
|
||||||
|
|
||||||
var regionCenterX = (region.Left + region.Right) / 2;
|
|
||||||
var regionCenterY = (region.Bottom + region.Top) / 2;
|
|
||||||
if (regionCenterX > bounds.Center.X) score *= 1.3;
|
|
||||||
if (regionCenterY < bounds.Center.Y) score *= 1.3;
|
|
||||||
|
|
||||||
if (score > bestScore)
|
|
||||||
{
|
|
||||||
bestScore = score;
|
|
||||||
bestRegion = region;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bestRegion;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Box> GenerateCandidateRegions(Box bounds)
|
|
||||||
{
|
|
||||||
var regions = new List<Box>();
|
|
||||||
var fractions = new[] { 0.25, 0.333, 0.5 };
|
|
||||||
|
|
||||||
foreach (var fx in fractions)
|
|
||||||
{
|
|
||||||
foreach (var fy in fractions)
|
|
||||||
{
|
|
||||||
var w = bounds.Length * fx;
|
|
||||||
var h = bounds.Width * fy;
|
|
||||||
|
|
||||||
regions.Add(new Box(bounds.Right - w, bounds.Bottom, w, h));
|
|
||||||
regions.Add(new Box(bounds.Left, bounds.Bottom, w, h));
|
|
||||||
regions.Add(new Box(bounds.Right - w, bounds.Top - h, w, h));
|
|
||||||
regions.Add(new Box(bounds.Left, bounds.Top - h, w, h));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var fy in fractions)
|
|
||||||
{
|
|
||||||
var h = bounds.Width * fy;
|
|
||||||
regions.Add(new Box(bounds.Left, bounds.Bottom, bounds.Length, h));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var fx in fractions)
|
|
||||||
{
|
|
||||||
var w = bounds.Length * fx;
|
|
||||||
regions.Add(new Box(bounds.Right - w, bounds.Bottom, w, bounds.Width));
|
|
||||||
}
|
|
||||||
|
|
||||||
return regions;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Line> FindOpenLines(List<Entity> entities)
|
|
||||||
{
|
|
||||||
var endpointUsers = new Dictionary<long, int>();
|
|
||||||
|
|
||||||
foreach (var entity in entities)
|
|
||||||
{
|
|
||||||
foreach (var ep in GetEntityEndpoints(entity))
|
|
||||||
{
|
|
||||||
var key = QuantizePoint(ep);
|
|
||||||
endpointUsers[key] = endpointUsers.GetValueOrDefault(key) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var openLines = new List<Line>();
|
|
||||||
foreach (var line in entities.OfType<Line>())
|
|
||||||
{
|
|
||||||
var startKey = QuantizePoint(line.StartPoint);
|
|
||||||
var endKey = QuantizePoint(line.EndPoint);
|
|
||||||
|
|
||||||
if (endpointUsers.GetValueOrDefault(startKey) <= 1 || endpointUsers.GetValueOrDefault(endKey) <= 1)
|
|
||||||
openLines.Add(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
return openLines;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Vector> GetEntityEndpoints(Entity entity)
|
|
||||||
{
|
|
||||||
return entity switch
|
|
||||||
{
|
|
||||||
Line line => new List<Vector> { line.StartPoint, line.EndPoint },
|
|
||||||
Arc arc => new List<Vector> { arc.StartPoint(), arc.EndPoint() },
|
|
||||||
_ => new List<Vector>()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long QuantizePoint(Vector pt)
|
|
||||||
{
|
|
||||||
var qx = (long)(pt.X * 1000);
|
|
||||||
var qy = (long)(pt.Y * 1000);
|
|
||||||
return qx * 100000000L + qy;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Vector? EntityCenter(Entity entity)
|
|
||||||
{
|
|
||||||
return entity switch
|
|
||||||
{
|
|
||||||
Line line => line.MidPoint,
|
|
||||||
Arc arc => arc.Center,
|
|
||||||
Circle circle => circle.Center,
|
|
||||||
_ => null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool RegionContains(Box box, Vector pt)
|
|
||||||
{
|
|
||||||
return pt.X >= box.Left && pt.X <= box.Right
|
|
||||||
&& pt.Y >= box.Bottom && pt.Y <= box.Top;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO.Ports;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace OpenNest.Posts.GravographIS
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Serial streamer for the Gravograph IS8000. 9600 8-N-1; flow control is
|
||||||
|
/// configurable and defaults to RTS/CTS (the controller is buffered and drops
|
||||||
|
/// CTS to apply backpressure). The job is sent in modest chunks rather than as
|
||||||
|
/// one giant write so the handshake can pause the write mid-stream.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GravographISPort : IDisposable
|
||||||
|
{
|
||||||
|
private SerialPort port;
|
||||||
|
|
||||||
|
public const int DefaultBaudRate = 9600;
|
||||||
|
public const int DefaultChunkSize = 256;
|
||||||
|
public const int DefaultWriteTimeoutMs = 30000;
|
||||||
|
|
||||||
|
public int ChunkSize { get; set; } = DefaultChunkSize;
|
||||||
|
public int WriteTimeoutMs { get; set; } = DefaultWriteTimeoutMs;
|
||||||
|
|
||||||
|
public bool IsOpen => port != null && port.IsOpen;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the port at the controller's required line settings (9600 8-N-1)
|
||||||
|
/// with the given <paramref name="handshake"/>. Throws if the port is
|
||||||
|
/// already open or if opening fails.
|
||||||
|
/// </summary>
|
||||||
|
public void Open(string portName, Handshake handshake = Handshake.RequestToSend)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(portName))
|
||||||
|
throw new ArgumentException("Port name is required.", nameof(portName));
|
||||||
|
if (port != null)
|
||||||
|
throw new InvalidOperationException("Port is already open.");
|
||||||
|
|
||||||
|
port = new SerialPort(portName, DefaultBaudRate, Parity.None, 8, StopBits.One)
|
||||||
|
{
|
||||||
|
Handshake = handshake,
|
||||||
|
WriteTimeout = WriteTimeoutMs,
|
||||||
|
ReadTimeout = WriteTimeoutMs,
|
||||||
|
// DTR/RTS are needed for some USB-serial bridges and for RTS/CTS flow:
|
||||||
|
DtrEnable = true,
|
||||||
|
RtsEnable = handshake != Handshake.RequestToSend &&
|
||||||
|
handshake != Handshake.RequestToSendXOnXOff,
|
||||||
|
};
|
||||||
|
|
||||||
|
port.Open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams the encoded job to the port in chunks. Cancellable. The chunked
|
||||||
|
/// write is intentional — Write() blocks until the OS accepts the bytes,
|
||||||
|
/// which with RTS/CTS or XOn/XOff yields cleanly when the controller's
|
||||||
|
/// buffer is full.
|
||||||
|
/// </summary>
|
||||||
|
public void StreamJob(byte[] data, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (data == null) throw new ArgumentNullException(nameof(data));
|
||||||
|
if (port == null || !port.IsOpen)
|
||||||
|
throw new InvalidOperationException("Port is not open.");
|
||||||
|
|
||||||
|
var chunk = ChunkSize > 0 ? ChunkSize : DefaultChunkSize;
|
||||||
|
var offset = 0;
|
||||||
|
|
||||||
|
while (offset < data.Length)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var count = System.Math.Min(chunk, data.Length - offset);
|
||||||
|
port.Write(data, offset, count);
|
||||||
|
offset += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block until the OS has handed the last bytes to the line. SerialPort
|
||||||
|
// doesn't expose flush-and-drain directly; BaseStream.Flush is a no-op
|
||||||
|
// on Windows, so this is best-effort.
|
||||||
|
try { port.BaseStream.Flush(); }
|
||||||
|
catch { /* ignored — Flush is advisory on SerialPort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
if (port == null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (port.IsOpen) port.Close();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
port.Dispose();
|
||||||
|
port = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Ports;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace OpenNest.Posts.GravographIS
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// IPostProcessor implementation for the Gravograph IS8000. <see cref="Post(Nest, Stream)"/>
|
||||||
|
/// writes the binary HPGL bytes. For serial streaming, use <see cref="Stream(Nest, string, Handshake, CancellationToken)"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GravographISPostProcessor : IPostProcessor
|
||||||
|
{
|
||||||
|
public string Name => "Gravograph IS8000";
|
||||||
|
public string Author => "OpenNest";
|
||||||
|
public string Description => "Gravograph IS8000 mechanical engraver (binary HPGL over serial)";
|
||||||
|
|
||||||
|
public GravographISWriterOptions WriterOptions { get; } = new GravographISWriterOptions();
|
||||||
|
|
||||||
|
public NestPolylineExtractor Extractor { get; } = new NestPolylineExtractor();
|
||||||
|
|
||||||
|
public double StitchTolerance { get; set; } = PolylinePrePass.DefaultStitchTolerance;
|
||||||
|
|
||||||
|
public bool AllowReverse { get; set; } = true;
|
||||||
|
|
||||||
|
public void Post(Nest nest, Stream outputStream)
|
||||||
|
{
|
||||||
|
if (nest == null) throw new ArgumentNullException(nameof(nest));
|
||||||
|
if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
|
||||||
|
|
||||||
|
var polylines = Extractor.Extract(nest);
|
||||||
|
var prepared = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse);
|
||||||
|
new GravographISWriter(WriterOptions).Write(prepared, outputStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Post(Nest nest, string outputFile)
|
||||||
|
{
|
||||||
|
using var fs = new FileStream(outputFile, FileMode.Create, FileAccess.Write);
|
||||||
|
Post(nest, fs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buffers the encoded job in memory, then streams it to the named COM port.
|
||||||
|
/// </summary>
|
||||||
|
public void Stream(Nest nest, string portName,
|
||||||
|
Handshake handshake = Handshake.RequestToSend,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
byte[] bytes;
|
||||||
|
using (var ms = new MemoryStream())
|
||||||
|
{
|
||||||
|
Post(nest, ms);
|
||||||
|
bytes = ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var port = new GravographISPort();
|
||||||
|
port.Open(portName, handshake);
|
||||||
|
port.StreamJob(bytes, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Posts.GravographIS
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL"
|
||||||
|
/// wire format. The byte stream is byte-exact against captures from GravoStyle'98.
|
||||||
|
///
|
||||||
|
/// Scale: 80 steps/mm = 2032 steps/inch. Y (and Z) are negated on the wire.
|
||||||
|
/// Deltas are signed big-endian int16 (max ±32767 steps ≈ ±16 inches per move).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GravographISWriter
|
||||||
|
{
|
||||||
|
// 93-byte preamble — captured from GravoStyle'98 with the trailing
|
||||||
|
// job-specific travel block stripped. The VS, VZ and DZ operands are
|
||||||
|
// patched by the writer to reflect feed and depth options.
|
||||||
|
//
|
||||||
|
// The original capture ended with a DR command (FF FD 44 52 00 00)
|
||||||
|
// followed by three 8-byte int16 records — same format as PU/PD —
|
||||||
|
// that carried a chunked travel from the head's parked position to
|
||||||
|
// the original job's first vertex (cumulative ΔX ≈ 1", ΔY ≈ 47").
|
||||||
|
// Those frozen deltas have nothing to do with our job geometry, so
|
||||||
|
// replaying them sends the head to a fixed point regardless of where
|
||||||
|
// the operator set zero. Stripped for the same reason as the captured
|
||||||
|
// fixed return-to-home block.
|
||||||
|
private static readonly byte[] PreambleTemplate = new byte[]
|
||||||
|
{
|
||||||
|
0x21, 0x41, 0x53, 0x20, 0x33, 0x38, 0x3b, 0x01, 0x90, 0x01,
|
||||||
|
0xf4, 0x01, 0x90, 0x01, 0xf4, 0x01, 0x90, 0x01, 0xf4, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x09, 0x00, 0x00, 0x03, 0xe8, 0x05, 0x06, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xfd, 0x32, 0x44, 0x00,
|
||||||
|
0x00, 0xff, 0xfd, 0x4d, 0x43, 0x00, 0x01, 0xff, 0xfd, 0x4f,
|
||||||
|
0x55, 0xff, 0xfb, 0xff, 0xfd, 0x4f, 0x55, 0xff, 0xfa, 0xff,
|
||||||
|
0xfd, 0x50, 0x5a, 0x00, 0x00, 0xff, 0xfd, 0x56, 0x53, 0x00,
|
||||||
|
0x23, 0xff, 0xfd, 0x56, 0x5a, 0x00, 0x23, 0xff, 0xfd, 0x44,
|
||||||
|
0x5a, 0x01, 0xfc,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stripped 36-byte postamble: lift, aux off, motor off, operator beep,
|
||||||
|
// job-finish. The 24-byte return-to-home block that appears in GravoStyle's
|
||||||
|
// captured postamble between MC and OP is intentionally OMITTED — those
|
||||||
|
// three 8-byte int16 records carry chunked job-specific return deltas
|
||||||
|
// (each record is [word1:int16][param:int16][ΔX:int16][ΔY:int16], same
|
||||||
|
// format as PU/PD records; the original capture chunked the long Y return
|
||||||
|
// across three records because each delta has to fit in int16). Reusing
|
||||||
|
// GravoStyle's frozen deltas on different geometry overshoots the X-axis
|
||||||
|
// limit. We emit calculated return deltas for the current job instead.
|
||||||
|
// The writer now replaces the captured fixed return block with a calculated
|
||||||
|
// lift + PU travel to the operator-set origin before these final commands.
|
||||||
|
private static readonly byte[] EndJobBytes = new byte[]
|
||||||
|
{
|
||||||
|
0xff, 0xfd, 0x4f, 0x55, 0xff, 0xfa, // OU 0xFFFA aux off
|
||||||
|
0xff, 0xfd, 0x4f, 0x55, 0xff, 0xfb, // OU 0xFFFB aux off
|
||||||
|
0xff, 0xfd, 0x4d, 0x43, 0x00, 0x00, // MC 0x0000 motor off
|
||||||
|
0xff, 0xfd, 0x4f, 0x50, 0x00, 0x00, // OP 0x0000 operator beep
|
||||||
|
0xff, 0xfd, 0x4a, 0x46, 0x00, 0x00, // JF 0x0000 job finish
|
||||||
|
};
|
||||||
|
|
||||||
|
// 80 steps/mm × 25.4 mm/in
|
||||||
|
internal const int StepsPerInch = 2032;
|
||||||
|
|
||||||
|
public GravographISWriterOptions Options { get; }
|
||||||
|
|
||||||
|
public GravographISWriter()
|
||||||
|
: this(new GravographISWriterOptions())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public GravographISWriter(GravographISWriterOptions options)
|
||||||
|
{
|
||||||
|
Options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the full byte stream (preamble + geometry + postamble) for the given
|
||||||
|
/// polylines. Polyline coordinates are in inches, relative to the operator-set
|
||||||
|
/// work origin. The writer emits a leading DR travel to the first polyline
|
||||||
|
/// start before lowering for the first cut.
|
||||||
|
/// </summary>
|
||||||
|
public void Write(IEnumerable<IReadOnlyList<Vector>> polylines, Stream output)
|
||||||
|
{
|
||||||
|
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
|
||||||
|
if (output == null) throw new ArgumentNullException(nameof(output));
|
||||||
|
|
||||||
|
var preamble = (byte[])PreambleTemplate.Clone();
|
||||||
|
PatchOperand(preamble, (byte)'V', (byte)'S', (short)Options.FeedMmPerSec);
|
||||||
|
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)Options.FeedMmPerSec);
|
||||||
|
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16());
|
||||||
|
output.Write(preamble, 0, preamble.Length);
|
||||||
|
|
||||||
|
// Cumulative head position from the operator-set upper-left origin, in
|
||||||
|
// wire steps. The first polyline gets a leading DR travel from this
|
||||||
|
// origin before PD lowers for cutting. Used by the envelope guard to
|
||||||
|
// catch bad records before they ship to the engraver.
|
||||||
|
var headX = 0;
|
||||||
|
var headY = 0;
|
||||||
|
var envelopeXSteps = (int)System.Math.Round(Options.WorkEnvelopeXMm * StepsPerMm,
|
||||||
|
MidpointRounding.AwayFromZero);
|
||||||
|
var envelopeYSteps = (int)System.Math.Round(Options.WorkEnvelopeYMm * StepsPerMm,
|
||||||
|
MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
var firstPolyline = true;
|
||||||
|
var polyIndex = 0;
|
||||||
|
|
||||||
|
foreach (var poly in polylines)
|
||||||
|
{
|
||||||
|
polyIndex++;
|
||||||
|
if (poly == null || poly.Count < 2)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var (startX, startY) = ToWire(poly[0]);
|
||||||
|
WriteTravel(output,
|
||||||
|
firstPolyline ? (byte)'D' : (byte)'P',
|
||||||
|
firstPolyline ? (byte)'R' : (byte)'U',
|
||||||
|
checked(startX - headX), checked(startY - headY),
|
||||||
|
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||||
|
|
||||||
|
// PD command + single records-follow flag, then one record per segment.
|
||||||
|
output.WriteByte(0xFF);
|
||||||
|
output.WriteByte(0xFD);
|
||||||
|
output.WriteByte((byte)'P');
|
||||||
|
output.WriteByte((byte)'D');
|
||||||
|
output.WriteByte(0x00);
|
||||||
|
output.WriteByte(0x00);
|
||||||
|
|
||||||
|
var prevX = startX;
|
||||||
|
var prevY = startY;
|
||||||
|
for (int i = 1; i < poly.Count; i++)
|
||||||
|
{
|
||||||
|
var (cx, cy) = ToWire(poly[i]);
|
||||||
|
var dx = checked(cx - prevX);
|
||||||
|
var dy = checked(cy - prevY);
|
||||||
|
EnsureEnvelope(headX + dx, headY + dy, envelopeXSteps, envelopeYSteps,
|
||||||
|
polyIndex, segment: i, isTravel: false);
|
||||||
|
WriteRecord(output, dx, dy);
|
||||||
|
prevX = cx;
|
||||||
|
prevY = cy;
|
||||||
|
headX += dx;
|
||||||
|
headY += dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
firstPolyline = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteLiftOnly(output);
|
||||||
|
if (Options.ReturnToOriginAtEnd && !firstPolyline)
|
||||||
|
{
|
||||||
|
WriteTravel(output, (byte)'P', (byte)'U',
|
||||||
|
checked(-headX), checked(-headY),
|
||||||
|
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||||
|
}
|
||||||
|
output.Write(EndJobBytes, 0, EndJobBytes.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const double StepsPerMm = 80.0;
|
||||||
|
|
||||||
|
private void EnsureEnvelope(int wireX, int wireY,
|
||||||
|
int envXSteps, int envYSteps,
|
||||||
|
int polyIndex, int segment, bool isTravel)
|
||||||
|
{
|
||||||
|
if (!Options.EnvelopeGuardEnabled) return;
|
||||||
|
|
||||||
|
// Wire frame: X is identity to input; Y is negated. With the operator
|
||||||
|
// origin set at the upper-left of the work envelope and an OpenNest
|
||||||
|
// quadrant-4 plate, valid part coordinates are +X/right and -Y/down:
|
||||||
|
// wireX ∈ [0, +envXSteps]
|
||||||
|
// wireY ∈ [0, +envYSteps]
|
||||||
|
if (wireX >= 0 && wireX <= envXSteps && wireY >= 0 && wireY <= envYSteps)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var inputX = wireX / (double)StepsPerInch;
|
||||||
|
var inputY = -wireY / (double)StepsPerInch;
|
||||||
|
var kind = isTravel ? "pen-up travel" : "cut segment";
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Polyline {polyIndex} {kind} (segment {segment}) would place the head at " +
|
||||||
|
$"({inputX:F3}\", {inputY:F3}\"), outside the {Options.WorkEnvelopeXMm}×{Options.WorkEnvelopeYMm} mm " +
|
||||||
|
$"work envelope from upper-left origin. Refusing to emit the record.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private short DepthInStepsAsInt16()
|
||||||
|
{
|
||||||
|
var steps = (long)System.Math.Round(Options.DepthInches * StepsPerInch, MidpointRounding.AwayFromZero);
|
||||||
|
if (steps < short.MinValue || steps > short.MaxValue)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(Options.DepthInches), $"Depth {Options.DepthInches} in. → {steps} steps overflows int16.");
|
||||||
|
return (short)steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (int x, int y) ToWire(Vector v)
|
||||||
|
{
|
||||||
|
// Inches -> steps. With upper-left origin in OpenNest quadrant 4,
|
||||||
|
// negative input Y is down; Y is negated on the wire.
|
||||||
|
var x = (int)System.Math.Round(v.X * StepsPerInch, MidpointRounding.AwayFromZero);
|
||||||
|
var y = (int)System.Math.Round(-v.Y * StepsPerInch, MidpointRounding.AwayFromZero);
|
||||||
|
return (x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteTravel(Stream s, byte c0, byte c1, int dx, int dy,
|
||||||
|
ref int headX, ref int headY,
|
||||||
|
int envelopeXSteps, int envelopeYSteps,
|
||||||
|
int polyIndex)
|
||||||
|
{
|
||||||
|
if (dx == 0 && dy == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
s.WriteByte(0xFF);
|
||||||
|
s.WriteByte(0xFD);
|
||||||
|
s.WriteByte(c0);
|
||||||
|
s.WriteByte(c1);
|
||||||
|
s.WriteByte(0x00);
|
||||||
|
s.WriteByte(0x00);
|
||||||
|
|
||||||
|
var chunks = System.Math.Max(
|
||||||
|
(int)System.Math.Ceiling(System.Math.Abs(dx) / (double)short.MaxValue),
|
||||||
|
(int)System.Math.Ceiling(System.Math.Abs(dy) / (double)short.MaxValue));
|
||||||
|
if (chunks < 1) chunks = 1;
|
||||||
|
|
||||||
|
var emittedX = 0;
|
||||||
|
var emittedY = 0;
|
||||||
|
for (var i = 1; i <= chunks; i++)
|
||||||
|
{
|
||||||
|
var targetX = (int)System.Math.Round(dx * (i / (double)chunks), MidpointRounding.AwayFromZero);
|
||||||
|
var targetY = (int)System.Math.Round(dy * (i / (double)chunks), MidpointRounding.AwayFromZero);
|
||||||
|
var chunkX = checked(targetX - emittedX);
|
||||||
|
var chunkY = checked(targetY - emittedY);
|
||||||
|
|
||||||
|
EnsureEnvelope(headX + chunkX, headY + chunkY, envelopeXSteps, envelopeYSteps,
|
||||||
|
polyIndex, segment: 0, isTravel: true);
|
||||||
|
WriteRecord(s, chunkX, chunkY);
|
||||||
|
|
||||||
|
emittedX = targetX;
|
||||||
|
emittedY = targetY;
|
||||||
|
headX += chunkX;
|
||||||
|
headY += chunkY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteLiftOnly(Stream s)
|
||||||
|
{
|
||||||
|
s.WriteByte(0xFF);
|
||||||
|
s.WriteByte(0xFD);
|
||||||
|
s.WriteByte((byte)'P');
|
||||||
|
s.WriteByte((byte)'U');
|
||||||
|
s.WriteByte(0x00);
|
||||||
|
s.WriteByte(0x01);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteCommandWithRecord(Stream s, byte c0, byte c1, int dx, int dy)
|
||||||
|
{
|
||||||
|
s.WriteByte(0xFF);
|
||||||
|
s.WriteByte(0xFD);
|
||||||
|
s.WriteByte(c0);
|
||||||
|
s.WriteByte(c1);
|
||||||
|
// Records-follow flag (0x0000) emitted once per PU/PD packet.
|
||||||
|
s.WriteByte(0x00);
|
||||||
|
s.WriteByte(0x00);
|
||||||
|
WriteRecord(s, dx, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteRecord(Stream s, int dx, int dy)
|
||||||
|
{
|
||||||
|
if (dx < short.MinValue || dx > short.MaxValue ||
|
||||||
|
dy < short.MinValue || dy > short.MaxValue)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Move delta ({dx}, {dy}) steps overflows signed int16 — split moves upstream.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int word1;
|
||||||
|
int param;
|
||||||
|
|
||||||
|
var absDx = (double)System.Math.Abs(dx);
|
||||||
|
var absDy = (double)System.Math.Abs(dy);
|
||||||
|
var len = System.Math.Sqrt(absDx * absDx + absDy * absDy);
|
||||||
|
|
||||||
|
if (len < 1.0)
|
||||||
|
{
|
||||||
|
// Zero-length lift (PU 00 01) is the dedicated form; for a record-carrying
|
||||||
|
// packet a true zero-length move shouldn't occur, but stay numerically safe.
|
||||||
|
word1 = 16384;
|
||||||
|
param = 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var maxAbs = System.Math.Max(absDx, absDy);
|
||||||
|
word1 = (int)System.Math.Round(16384.0 * maxAbs / len, MidpointRounding.AwayFromZero);
|
||||||
|
param = (int)System.Math.Round(len / 22.4, MidpointRounding.AwayFromZero);
|
||||||
|
if (param < 1) param = 1;
|
||||||
|
if (param > 180) param = 180;
|
||||||
|
if (word1 > 16384) word1 = 16384;
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteBigEndianInt16(s, (short)word1);
|
||||||
|
WriteBigEndianInt16(s, (short)param);
|
||||||
|
WriteBigEndianInt16(s, (short)dx);
|
||||||
|
WriteBigEndianInt16(s, (short)dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteBigEndianInt16(Stream s, short value)
|
||||||
|
{
|
||||||
|
s.WriteByte((byte)((value >> 8) & 0xFF));
|
||||||
|
s.WriteByte((byte)(value & 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locates the operand of a command (FF FD <c0> <c1> <hi> <lo>) and overwrites it.
|
||||||
|
// Throws if the command isn't present — that would mean the preamble was mis-edited.
|
||||||
|
private static void PatchOperand(byte[] buffer, byte c0, byte c1, short value)
|
||||||
|
{
|
||||||
|
for (int i = 0; i <= buffer.Length - 6; i++)
|
||||||
|
{
|
||||||
|
if (buffer[i] == 0xFF && buffer[i + 1] == 0xFD &&
|
||||||
|
buffer[i + 2] == c0 && buffer[i + 3] == c1)
|
||||||
|
{
|
||||||
|
buffer[i + 4] = (byte)((value >> 8) & 0xFF);
|
||||||
|
buffer[i + 5] = (byte)(value & 0xFF);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Command '{(char)c0}{(char)c1}' not found in preamble template.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace OpenNest.Posts.GravographIS
|
||||||
|
{
|
||||||
|
public sealed class GravographISWriterOptions
|
||||||
|
{
|
||||||
|
public double DepthInches { get; set; } = 0.25;
|
||||||
|
|
||||||
|
public int FeedMmPerSec { get; set; } = 35;
|
||||||
|
|
||||||
|
// IS8000 work envelope in millimeters, from the operator-set upper-left
|
||||||
|
// work origin. Defaults to the catalog 0.610 m x 1.220 m bed. With an
|
||||||
|
// OpenNest quadrant-4 plate, motion is allowed right (+X) and down (-Y).
|
||||||
|
public double WorkEnvelopeXMm { get; set; } = 610.0;
|
||||||
|
public double WorkEnvelopeYMm { get; set; } = 1220.0;
|
||||||
|
|
||||||
|
// When true, the writer throws an InvalidOperationException naming the
|
||||||
|
// offending polyline and segment before any out-of-envelope record is
|
||||||
|
// emitted. Disable only for off-machine encoding tests.
|
||||||
|
public bool EnvelopeGuardEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
// When true, lift at the end of the last cut and return to the
|
||||||
|
// operator-set origin before shutting the job down.
|
||||||
|
public bool ReturnToOriginAtEnd { get; set; } = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Posts.GravographIS
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lifts polylines out of an OpenNest <see cref="Nest"/> for the Gravograph
|
||||||
|
/// backend. Walks each <see cref="Part"/>'s <see cref="Program"/>, breaks
|
||||||
|
/// polylines at rapid moves, and tessellates arcs to a chord-deviation
|
||||||
|
/// tolerance (the wire format takes line segments only).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NestPolylineExtractor
|
||||||
|
{
|
||||||
|
public double ArcChordToleranceInches { get; set; } = 0.001;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts polylines from every non-cutoff part in every plate of the nest,
|
||||||
|
/// returning them in plate coordinates (inches).
|
||||||
|
/// </summary>
|
||||||
|
public List<List<Vector>> Extract(Nest nest)
|
||||||
|
{
|
||||||
|
if (nest == null) throw new ArgumentNullException(nameof(nest));
|
||||||
|
|
||||||
|
var result = new List<List<Vector>>();
|
||||||
|
|
||||||
|
foreach (var plate in nest.Plates)
|
||||||
|
{
|
||||||
|
foreach (var part in plate.Parts)
|
||||||
|
{
|
||||||
|
if (part.BaseDrawing != null && part.BaseDrawing.IsCutOff)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
ExtractPart(part, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts polylines for a single part. Public so callers driving the
|
||||||
|
/// writer directly (e.g. from a console one-off) can use it.
|
||||||
|
/// </summary>
|
||||||
|
public List<List<Vector>> ExtractPart(Part part)
|
||||||
|
{
|
||||||
|
var list = new List<List<Vector>>();
|
||||||
|
ExtractPart(part, list);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExtractPart(Part part, List<List<Vector>> sink)
|
||||||
|
{
|
||||||
|
var program = part.Program;
|
||||||
|
if (program == null) return;
|
||||||
|
|
||||||
|
// The walk below treats Motion.EndPoint as absolute. Convert a working
|
||||||
|
// copy to absolute mode so G91 programs (the form OpenNest's UI writes)
|
||||||
|
// produce correct geometry. Cloning keeps part.Program untouched.
|
||||||
|
if (program.Mode == Mode.Incremental)
|
||||||
|
{
|
||||||
|
program = (Program)program.Clone();
|
||||||
|
program.Mode = Mode.Absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = part.Location;
|
||||||
|
var pos = new Vector(0, 0);
|
||||||
|
List<Vector> current = null;
|
||||||
|
|
||||||
|
foreach (var code in program.Codes)
|
||||||
|
{
|
||||||
|
if (code is Motion m && m.Suppressed)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
switch (code)
|
||||||
|
{
|
||||||
|
case RapidMove rapid:
|
||||||
|
{
|
||||||
|
FlushCurrent(sink, ref current);
|
||||||
|
pos = rapid.EndPoint;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case LinearMove linear:
|
||||||
|
{
|
||||||
|
if (current == null)
|
||||||
|
{
|
||||||
|
current = new List<Vector> { pos + offset };
|
||||||
|
}
|
||||||
|
var end = linear.EndPoint;
|
||||||
|
current.Add(end + offset);
|
||||||
|
pos = end;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case ArcMove arc:
|
||||||
|
{
|
||||||
|
if (current == null)
|
||||||
|
{
|
||||||
|
current = new List<Vector> { pos + offset };
|
||||||
|
}
|
||||||
|
TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
|
||||||
|
pos = arc.EndPoint;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlushCurrent(sink, ref current);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FlushCurrent(List<List<Vector>> sink, ref List<Vector> current)
|
||||||
|
{
|
||||||
|
if (current != null && current.Count >= 2)
|
||||||
|
sink.Add(current);
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample points along an arc to within chordTol of the true curve. start is
|
||||||
|
// the arc's start point (current pen position), arc.CenterPoint is absolute
|
||||||
|
// (G-code I/J in this codebase are stored as the absolute center), arc.EndPoint
|
||||||
|
// is absolute end. The starting point is assumed to already be in the polyline;
|
||||||
|
// intermediate samples and the endpoint are appended.
|
||||||
|
private static void TessellateArc(Vector start, ArcMove arc, Vector offset,
|
||||||
|
double chordTol, List<Vector> sink)
|
||||||
|
{
|
||||||
|
var c = arc.CenterPoint;
|
||||||
|
var r = c.DistanceTo(start);
|
||||||
|
if (r < 1e-9)
|
||||||
|
{
|
||||||
|
sink.Add(arc.EndPoint + offset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var a0 = System.Math.Atan2(start.Y - c.Y, start.X - c.X);
|
||||||
|
var a1 = System.Math.Atan2(arc.EndPoint.Y - c.Y, arc.EndPoint.X - c.X);
|
||||||
|
|
||||||
|
double sweep;
|
||||||
|
if (arc.Rotation == RotationType.CW)
|
||||||
|
{
|
||||||
|
sweep = a0 - a1;
|
||||||
|
if (sweep <= 0) sweep += 2 * System.Math.PI;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sweep = a1 - a0;
|
||||||
|
if (sweep <= 0) sweep += 2 * System.Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat a near-zero sweep with coincident start/end as a full circle.
|
||||||
|
if (sweep < 1e-9 &&
|
||||||
|
System.Math.Abs(start.X - arc.EndPoint.X) < 1e-9 &&
|
||||||
|
System.Math.Abs(start.Y - arc.EndPoint.Y) < 1e-9)
|
||||||
|
{
|
||||||
|
sweep = 2 * System.Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max angle step from chord-deviation tolerance: dev = r * (1 - cos(t/2)).
|
||||||
|
var maxAngleStep = 2.0 * System.Math.Acos(System.Math.Max(0.0, 1.0 - chordTol / r));
|
||||||
|
if (double.IsNaN(maxAngleStep) || maxAngleStep <= 0)
|
||||||
|
maxAngleStep = System.Math.PI / 32;
|
||||||
|
|
||||||
|
var steps = (int)System.Math.Ceiling(sweep / maxAngleStep);
|
||||||
|
if (steps < 1) steps = 1;
|
||||||
|
|
||||||
|
var direction = arc.Rotation == RotationType.CW ? -1.0 : 1.0;
|
||||||
|
for (int i = 1; i < steps; i++)
|
||||||
|
{
|
||||||
|
var t = sweep * (i / (double)steps);
|
||||||
|
var ang = a0 + direction * t;
|
||||||
|
var pt = new Vector(c.X + r * System.Math.Cos(ang), c.Y + r * System.Math.Sin(ang));
|
||||||
|
sink.Add(pt + offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
sink.Add(arc.EndPoint + offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<RootNamespace>OpenNest.Posts.GravographIS</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||||
|
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Target Name="CopyToPostsDir" AfterTargets="Build">
|
||||||
|
<PropertyGroup>
|
||||||
|
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
|
||||||
|
</PropertyGroup>
|
||||||
|
<MakeDir Directories="$(PostsDir)" />
|
||||||
|
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Posts.GravographIS
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Geometry pre-pass for the Gravograph IS8000 backend. The machine is a dumb
|
||||||
|
/// executor — it never reorders geometry and always lifts between separate
|
||||||
|
/// entities — so we stitch shared-endpoint polylines together and reorder by
|
||||||
|
/// nearest-neighbor before encoding.
|
||||||
|
/// </summary>
|
||||||
|
public static class PolylinePrePass
|
||||||
|
{
|
||||||
|
public const double DefaultStitchTolerance = 1e-6;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Joins polylines whose endpoints coincide (within <paramref name="tolerance"/>)
|
||||||
|
/// into single continuous polylines. Polylines with fewer than two points are
|
||||||
|
/// dropped. Direction is reversed as needed to make a join. Each input polyline
|
||||||
|
/// is copied — the inputs are not mutated.
|
||||||
|
/// </summary>
|
||||||
|
public static List<List<Vector>> Stitch(
|
||||||
|
IEnumerable<IReadOnlyList<Vector>> polylines,
|
||||||
|
double tolerance = DefaultStitchTolerance)
|
||||||
|
{
|
||||||
|
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
|
||||||
|
|
||||||
|
var segs = new List<List<Vector>>();
|
||||||
|
foreach (var p in polylines)
|
||||||
|
{
|
||||||
|
if (p == null || p.Count < 2)
|
||||||
|
continue;
|
||||||
|
segs.Add(new List<Vector>(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool changed;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
changed = false;
|
||||||
|
for (int i = 0; i < segs.Count; i++)
|
||||||
|
{
|
||||||
|
var a = segs[i];
|
||||||
|
|
||||||
|
for (int j = 0; j < segs.Count; j++)
|
||||||
|
{
|
||||||
|
if (i == j) continue;
|
||||||
|
var b = segs[j];
|
||||||
|
|
||||||
|
// a-end ↔ b-start: append b to a (skip duplicated joint)
|
||||||
|
if (Near(a[a.Count - 1], b[0], tolerance))
|
||||||
|
{
|
||||||
|
for (int k = 1; k < b.Count; k++) a.Add(b[k]);
|
||||||
|
segs.RemoveAt(j);
|
||||||
|
if (j < i) i--;
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a-end ↔ b-end: append reversed b to a
|
||||||
|
if (Near(a[a.Count - 1], b[b.Count - 1], tolerance))
|
||||||
|
{
|
||||||
|
for (int k = b.Count - 2; k >= 0; k--) a.Add(b[k]);
|
||||||
|
segs.RemoveAt(j);
|
||||||
|
if (j < i) i--;
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a-start ↔ b-end: prepend b to a
|
||||||
|
if (Near(a[0], b[b.Count - 1], tolerance))
|
||||||
|
{
|
||||||
|
var combined = new List<Vector>(b.Count + a.Count - 1);
|
||||||
|
combined.AddRange(b);
|
||||||
|
for (int k = 1; k < a.Count; k++) combined.Add(a[k]);
|
||||||
|
segs[i] = combined;
|
||||||
|
segs.RemoveAt(j);
|
||||||
|
if (j < i) i--;
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a-start ↔ b-start: prepend reversed b to a
|
||||||
|
if (Near(a[0], b[0], tolerance))
|
||||||
|
{
|
||||||
|
var combined = new List<Vector>(b.Count + a.Count - 1);
|
||||||
|
for (int k = b.Count - 1; k >= 0; k--) combined.Add(b[k]);
|
||||||
|
for (int k = 1; k < a.Count; k++) combined.Add(a[k]);
|
||||||
|
segs[i] = combined;
|
||||||
|
segs.RemoveAt(j);
|
||||||
|
if (j < i) i--;
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (changed);
|
||||||
|
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Greedy nearest-neighbor ordering of polylines starting from
|
||||||
|
/// <paramref name="origin"/> (defaults to 0,0 = the work origin = the first
|
||||||
|
/// polyline's first point on the wire). When <paramref name="allowReverse"/>
|
||||||
|
/// is true a polyline may be reversed if its tail is closer than its head.
|
||||||
|
/// </summary>
|
||||||
|
public static List<List<Vector>> Reorder(
|
||||||
|
IEnumerable<IReadOnlyList<Vector>> polylines,
|
||||||
|
bool allowReverse = true,
|
||||||
|
Vector? origin = null)
|
||||||
|
{
|
||||||
|
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
|
||||||
|
|
||||||
|
var pool = new List<List<Vector>>();
|
||||||
|
foreach (var p in polylines)
|
||||||
|
{
|
||||||
|
if (p == null || p.Count < 2)
|
||||||
|
continue;
|
||||||
|
pool.Add(new List<Vector>(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
var ordered = new List<List<Vector>>(pool.Count);
|
||||||
|
var current = origin ?? new Vector(0, 0);
|
||||||
|
|
||||||
|
while (pool.Count > 0)
|
||||||
|
{
|
||||||
|
var bestIdx = -1;
|
||||||
|
var bestReverse = false;
|
||||||
|
var bestDistSq = double.PositiveInfinity;
|
||||||
|
|
||||||
|
for (int i = 0; i < pool.Count; i++)
|
||||||
|
{
|
||||||
|
var p = pool[i];
|
||||||
|
var dHead = SquaredDistance(current, p[0]);
|
||||||
|
if (dHead < bestDistSq)
|
||||||
|
{
|
||||||
|
bestDistSq = dHead;
|
||||||
|
bestIdx = i;
|
||||||
|
bestReverse = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowReverse)
|
||||||
|
{
|
||||||
|
var dTail = SquaredDistance(current, p[p.Count - 1]);
|
||||||
|
if (dTail < bestDistSq)
|
||||||
|
{
|
||||||
|
bestDistSq = dTail;
|
||||||
|
bestIdx = i;
|
||||||
|
bestReverse = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var pick = pool[bestIdx];
|
||||||
|
pool.RemoveAt(bestIdx);
|
||||||
|
if (bestReverse)
|
||||||
|
pick.Reverse();
|
||||||
|
ordered.Add(pick);
|
||||||
|
current = pick[pick.Count - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience: stitch then reorder.
|
||||||
|
/// </summary>
|
||||||
|
public static List<List<Vector>> Prepare(
|
||||||
|
IEnumerable<IReadOnlyList<Vector>> polylines,
|
||||||
|
double stitchTolerance = DefaultStitchTolerance,
|
||||||
|
bool allowReverse = true,
|
||||||
|
Vector? origin = null)
|
||||||
|
{
|
||||||
|
var stitched = Stitch(polylines, stitchTolerance);
|
||||||
|
return Reorder(stitched, allowReverse, origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Near(Vector a, Vector b, double tol)
|
||||||
|
{
|
||||||
|
var dx = a.X - b.X;
|
||||||
|
var dy = a.Y - b.Y;
|
||||||
|
return (dx * dx + dy * dy) <= tol * tol;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double SquaredDistance(Vector a, Vector b)
|
||||||
|
{
|
||||||
|
var dx = a.X - b.X;
|
||||||
|
var dy = a.Y - b.Y;
|
||||||
|
return dx * dx + dy * dy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.BestFit;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
using OpenNest.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.BestFit;
|
||||||
|
|
||||||
|
public class BestFitResultFrameTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildCanonicalParts_NonAxisAlignedPairNormalizesActualBounds()
|
||||||
|
{
|
||||||
|
var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing();
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
|
||||||
|
var result = EvaluateOffsetPair(canonical, new Vector(40, 30));
|
||||||
|
|
||||||
|
Assert.True(IsNonAxisAligned(result.OptimalRotation),
|
||||||
|
$"Expected a non-axis-aligned result, got {Angle.ToDegrees(result.OptimalRotation):F2} degrees.");
|
||||||
|
|
||||||
|
var parts = result.BuildCanonicalParts();
|
||||||
|
var bounds = result.GetCutBounds(parts);
|
||||||
|
|
||||||
|
Assert.Equal(0, bounds.Left, 3);
|
||||||
|
Assert.Equal(0, bounds.Bottom, 3);
|
||||||
|
Assert.Equal(result.BoundingWidth, bounds.Length, 2);
|
||||||
|
Assert.Equal(result.BoundingHeight, bounds.Width, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildSourceParts_RebindsCanonicalResultToRotatedSourceDrawing()
|
||||||
|
{
|
||||||
|
var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing();
|
||||||
|
drawing.Program.Rotate(Angle.ToRadians(30), drawing.Program.BoundingBox().Center);
|
||||||
|
drawing.RecomputeCanonicalAngle();
|
||||||
|
|
||||||
|
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||||
|
var result = EvaluateOffsetPair(canonical, new Vector(40, 30));
|
||||||
|
|
||||||
|
var parts = result.BuildSourceParts(drawing);
|
||||||
|
var bounds = result.GetCutBounds(parts);
|
||||||
|
|
||||||
|
Assert.All(parts, p => Assert.Same(drawing, p.BaseDrawing));
|
||||||
|
Assert.Equal(0, bounds.Left, 3);
|
||||||
|
Assert.Equal(0, bounds.Bottom, 3);
|
||||||
|
Assert.False(parts[0].Intersects(parts[1], out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BestFitResult EvaluateOffsetPair(Drawing drawing, Vector offset)
|
||||||
|
{
|
||||||
|
var candidate = new PairCandidate
|
||||||
|
{
|
||||||
|
Drawing = drawing,
|
||||||
|
Part1Rotation = 0,
|
||||||
|
Part2Rotation = System.Math.PI,
|
||||||
|
Part2Offset = offset,
|
||||||
|
Spacing = 0.25
|
||||||
|
};
|
||||||
|
|
||||||
|
return new PairEvaluator().Evaluate(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNonAxisAligned(double angle)
|
||||||
|
{
|
||||||
|
var normalized = Angle.NormalizeRad(angle);
|
||||||
|
var nearestQuadrant = Angle.HalfPI * System.Math.Round(normalized / Angle.HalfPI);
|
||||||
|
var delta = System.Math.Abs(normalized - nearestQuadrant);
|
||||||
|
delta = System.Math.Min(delta, Angle.HalfPI - delta);
|
||||||
|
return delta > Angle.ToRadians(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,33 @@ namespace OpenNest.Tests.Fill
|
|||||||
return part;
|
return part;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Drawing MakeTriangleDrawing(params Vector[] points)
|
||||||
|
{
|
||||||
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.RapidMove(points[0]));
|
||||||
|
|
||||||
|
for (var i = 1; i < points.Length; i++)
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.LinearMove(points[i]));
|
||||||
|
|
||||||
|
pgm.Codes.Add(new OpenNest.CNC.LinearMove(points[0]));
|
||||||
|
return new Drawing("triangle", pgm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part MakeTrianglePart(params Vector[] points)
|
||||||
|
{
|
||||||
|
var part = new Part(MakeTriangleDrawing(points));
|
||||||
|
part.UpdateBounds();
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part MakeTrianglePart(double x, double y, params Vector[] points)
|
||||||
|
{
|
||||||
|
var part = MakeTrianglePart(points);
|
||||||
|
part.Location = new Vector(x, y);
|
||||||
|
part.UpdateBounds();
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Push_Left_MovesPartTowardEdge()
|
public void Push_Left_MovesPartTowardEdge()
|
||||||
{
|
{
|
||||||
@@ -171,6 +198,86 @@ namespace OpenNest.Tests.Fill
|
|||||||
Assert.NotEqual(distNoSpacing, distWithSpacing);
|
Assert.NotEqual(distNoSpacing, distWithSpacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Push_Up_AllowsSharedDiagonalEdgeToSeparate()
|
||||||
|
{
|
||||||
|
var workArea = new Box(0, 0, 20, 20);
|
||||||
|
var obstacle = MakeTrianglePart(
|
||||||
|
new Vector(0, 0),
|
||||||
|
new Vector(10, 0),
|
||||||
|
new Vector(0, 10));
|
||||||
|
var movingPart = MakeTrianglePart(
|
||||||
|
new Vector(0, 10),
|
||||||
|
new Vector(10, 0),
|
||||||
|
new Vector(10, 10));
|
||||||
|
|
||||||
|
var distance = Compactor.Push(
|
||||||
|
new List<Part> { movingPart },
|
||||||
|
new List<Part> { obstacle },
|
||||||
|
workArea,
|
||||||
|
0,
|
||||||
|
PushDirection.Up);
|
||||||
|
|
||||||
|
Assert.True(distance > 0);
|
||||||
|
Assert.True(movingPart.BoundingBox.Top > 19.9);
|
||||||
|
Assert.False(movingPart.Intersects(obstacle, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Push_Up_MovesAfterRightTriangleIsPushedLeftIntoSharedEdge()
|
||||||
|
{
|
||||||
|
var workArea = new Box(0, 0, 24, 24);
|
||||||
|
var leftTriangle = MakeTrianglePart(
|
||||||
|
2, 2,
|
||||||
|
new Vector(0, 0),
|
||||||
|
new Vector(8, 0),
|
||||||
|
new Vector(4, 10));
|
||||||
|
var rightTriangle = MakeTrianglePart(
|
||||||
|
14, 4,
|
||||||
|
new Vector(0, 10),
|
||||||
|
new Vector(8, 10),
|
||||||
|
new Vector(4, 0));
|
||||||
|
|
||||||
|
var moving = new List<Part> { rightTriangle };
|
||||||
|
var obstacles = new List<Part> { leftTriangle };
|
||||||
|
|
||||||
|
var leftDistance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Left);
|
||||||
|
var yBeforePushUp = rightTriangle.Location.Y;
|
||||||
|
var bottomBeforePushUp = rightTriangle.BoundingBox.Bottom;
|
||||||
|
|
||||||
|
var upDistance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Up);
|
||||||
|
|
||||||
|
Assert.True(leftDistance > 0);
|
||||||
|
Assert.True(upDistance > 0);
|
||||||
|
Assert.True(rightTriangle.Location.Y > yBeforePushUp);
|
||||||
|
Assert.True(rightTriangle.BoundingBox.Bottom > bottomBeforePushUp);
|
||||||
|
Assert.False(rightTriangle.Intersects(leftTriangle, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Push_Left_BlocksWhenSharedDiagonalEdgeWouldOverlap()
|
||||||
|
{
|
||||||
|
var workArea = new Box(0, 0, 20, 20);
|
||||||
|
var obstacle = MakeTrianglePart(
|
||||||
|
new Vector(0, 0),
|
||||||
|
new Vector(10, 0),
|
||||||
|
new Vector(0, 10));
|
||||||
|
var movingPart = MakeTrianglePart(
|
||||||
|
new Vector(0, 10),
|
||||||
|
new Vector(10, 0),
|
||||||
|
new Vector(10, 10));
|
||||||
|
|
||||||
|
var distance = Compactor.Push(
|
||||||
|
new List<Part> { movingPart },
|
||||||
|
new List<Part> { obstacle },
|
||||||
|
workArea,
|
||||||
|
0,
|
||||||
|
PushDirection.Left);
|
||||||
|
|
||||||
|
Assert.Equal(0, distance);
|
||||||
|
Assert.Equal(0, movingPart.BoundingBox.Left);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Push_AngleLeft_MovesPartTowardEdge()
|
public void Push_AngleLeft_MovesPartTowardEdge()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Posts.GravographIS;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.GravographIS;
|
||||||
|
|
||||||
|
public class EnvelopeGuardTests
|
||||||
|
{
|
||||||
|
// 0.610 m / 0.0125 mm/step = 48 800 steps = 24.0157 inches
|
||||||
|
// 1.220 m / 0.0125 mm/step = 97 600 steps = 48.0315 inches
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NegativeX_FromOrigin_Throws()
|
||||||
|
{
|
||||||
|
// Operator origin is upper-left; quadrant 4 walks right/down. A cut that walks
|
||||||
|
// left of origin in -X must be refused.
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(-1, 0) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var ex = Assert.Throws<System.InvalidOperationException>(() =>
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
new GravographISWriter().Write(polylines, ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Contains("Polyline 1", ex.Message);
|
||||||
|
Assert.Contains("cut segment", ex.Message);
|
||||||
|
Assert.Contains("segment 1", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PositiveY_FromOrigin_Throws()
|
||||||
|
{
|
||||||
|
// Positive input-Y is above the upper-left origin in quadrant 4.
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(0, 1) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var ex = Assert.Throws<System.InvalidOperationException>(() =>
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
new GravographISWriter().Write(polylines, ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Contains("Polyline 1", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void XExceedsEnvelope_Throws_AndNamesSegment()
|
||||||
|
{
|
||||||
|
// 25" in X is past the 0.610 m (~24.02") envelope.
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(10, 0), new Vector(25, 0) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var ex = Assert.Throws<System.InvalidOperationException>(() =>
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
new GravographISWriter().Write(polylines, ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Contains("Polyline 1", ex.Message);
|
||||||
|
Assert.Contains("segment 2", ex.Message); // 0→10 ok; 10→25 trips
|
||||||
|
Assert.Contains("25.000\"", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void YExceedsEnvelope_Throws()
|
||||||
|
{
|
||||||
|
// -49" in Y is past the 1.220 m (~48.03") envelope.
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(0, -49) },
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<System.InvalidOperationException>(() =>
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
new GravographISWriter().Write(polylines, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PenUpTravel_OutsideEnvelope_AlsoThrows_AndIsLabeledTravel()
|
||||||
|
{
|
||||||
|
// Polyline 1 ends in-envelope; the PU travel to polyline 2 leaves it.
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(1, 0) },
|
||||||
|
new[] { new Vector(30, 0), new Vector(30, -1) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var ex = Assert.Throws<System.InvalidOperationException>(() =>
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
new GravographISWriter().Write(polylines, ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Contains("Polyline 2", ex.Message);
|
||||||
|
Assert.Contains("pen-up travel", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RightAtEnvelopeCorner_IsAllowed()
|
||||||
|
{
|
||||||
|
// Walk to (24", -48") in int16-sized hops (each delta < 16.1"). The
|
||||||
|
// catalog envelope is 24.02" × 48.03", so this lands just inside.
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new Vector(0, 0),
|
||||||
|
new Vector(8, -16),
|
||||||
|
new Vector(16, -32),
|
||||||
|
new Vector(24, -48),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
new GravographISWriter().Write(polylines, ms); // no throw
|
||||||
|
Assert.True(ms.Length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnvelopeGuard_CanBeDisabled_ForOffMachineEncoding()
|
||||||
|
{
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(-5, 0) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var opts = new GravographISWriterOptions { EnvelopeGuardEnabled = false };
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
new GravographISWriter(opts).Write(polylines, ms); // no throw
|
||||||
|
Assert.True(ms.Length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CustomEnvelope_TightensTheCheck()
|
||||||
|
{
|
||||||
|
// Restrict to 1" × 1" — a 2" line in -Y now overshoots.
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(0, -2) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var opts = new GravographISWriterOptions
|
||||||
|
{
|
||||||
|
WorkEnvelopeXMm = 25.4,
|
||||||
|
WorkEnvelopeYMm = 25.4,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<System.InvalidOperationException>(() =>
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
new GravographISWriter(opts).Write(polylines, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Posts.GravographIS;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.GravographIS;
|
||||||
|
|
||||||
|
public class GravographISWriterTests
|
||||||
|
{
|
||||||
|
// 93-byte preamble captured from GravoStyle'98 (VS/VZ=35, DZ=508 → matches defaults).
|
||||||
|
// The original capture ended with a DR command (FF FD 44 52) followed by three
|
||||||
|
// 8-byte int16 records carrying a chunked job-specific travel (~1" X, ~47" Y).
|
||||||
|
// Stripped from the writer (see GravographISWriter.PreambleTemplate) because
|
||||||
|
// those frozen deltas send the head to a fixed point regardless of the job. The
|
||||||
|
// writer now emits a job-specific leading DR travel from operator zero instead.
|
||||||
|
private const string PreambleHex =
|
||||||
|
"21 41 53 20 33 38 3b 01 90 01 f4 01 90 01 f4 01 90 01 f4 00 00 00 00 00 00 00 00 00 00 " +
|
||||||
|
"00 00 00 09 00 00 03 e8 05 06 00 00 00 00 00 00 ff fd 32 44 00 00 ff fd 4d 43 00 01 ff fd " +
|
||||||
|
"4f 55 ff fb ff fd 4f 55 ff fa ff fd 50 5a 00 00 ff fd 56 53 00 23 ff fd 56 5a 00 23 ff fd " +
|
||||||
|
"44 5a 01 fc";
|
||||||
|
|
||||||
|
// Legacy 36-byte tail with lift, aux off, motor off, operator beep, job finish.
|
||||||
|
// Byte-exact capture tests disable dynamic return-to-origin to preserve this form.
|
||||||
|
private const string PostambleHex =
|
||||||
|
"ff fd 50 55 00 01 ff fd 4f 55 ff fa ff fd 4f 55 ff fb ff fd 4d 43 00 00 " +
|
||||||
|
"ff fd 4f 50 00 00 ff fd 4a 46 00 00";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TestA_SingleTwoInchVerticalLine_IsByteExact()
|
||||||
|
{
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(1, 1), new Vector(1, 3) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var writer = new GravographISWriter(new GravographISWriterOptions
|
||||||
|
{
|
||||||
|
DepthInches = 0.25,
|
||||||
|
FeedMmPerSec = 35,
|
||||||
|
EnvelopeGuardEnabled = false,
|
||||||
|
ReturnToOriginAtEnd = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
writer.Write(polylines, ms);
|
||||||
|
|
||||||
|
const string GeomHex =
|
||||||
|
"ff fd 44 52 00 00 2d 41 00 80 07 f0 f8 10 " +
|
||||||
|
"ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20";
|
||||||
|
var expected = HexToBytes(PreambleHex + " " + GeomHex + " " + PostambleHex);
|
||||||
|
|
||||||
|
Assert.Equal(expected, ms.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TestB_FourLines_IsByteExact()
|
||||||
|
{
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(1, 1), new Vector(1, 3) },
|
||||||
|
new[] { new Vector(4, 1), new Vector(4, 3) },
|
||||||
|
new[] { new Vector(4, 5), new Vector(4, 7) },
|
||||||
|
new[] { new Vector(1, 5), new Vector(1, 7) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var writer = new GravographISWriter(new GravographISWriterOptions
|
||||||
|
{
|
||||||
|
DepthInches = 0.25,
|
||||||
|
FeedMmPerSec = 35,
|
||||||
|
EnvelopeGuardEnabled = false,
|
||||||
|
ReturnToOriginAtEnd = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
writer.Write(polylines, ms);
|
||||||
|
|
||||||
|
const string GeomHex =
|
||||||
|
"ff fd 44 52 00 00 2d 41 00 80 07 f0 f8 10 " +
|
||||||
|
"ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20 " +
|
||||||
|
"ff fd 50 55 00 00 35 40 00 b4 17 d0 0f e0 " +
|
||||||
|
"ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20 " +
|
||||||
|
"ff fd 50 55 00 00 40 00 00 b4 00 00 f0 20 " +
|
||||||
|
"ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20 " +
|
||||||
|
"ff fd 50 55 00 00 35 40 00 b4 e8 30 0f e0 " +
|
||||||
|
"ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20";
|
||||||
|
var expected = HexToBytes(PreambleHex + " " + GeomHex + " " + PostambleHex);
|
||||||
|
|
||||||
|
Assert.Equal(expected, ms.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LeadingDR_TravelsToFirstPolylineStartBeforePD()
|
||||||
|
{
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(2, 2), new Vector(3, 2) },
|
||||||
|
};
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false }).Write(polylines, ms);
|
||||||
|
|
||||||
|
var bytes = ms.ToArray();
|
||||||
|
// First command after the 93-byte preamble must be DR to the first point,
|
||||||
|
// followed by PD for the first cut.
|
||||||
|
Assert.Equal(0xFF, bytes[93]);
|
||||||
|
Assert.Equal(0xFD, bytes[94]);
|
||||||
|
Assert.Equal((byte)'D', bytes[95]);
|
||||||
|
Assert.Equal((byte)'R', bytes[96]);
|
||||||
|
|
||||||
|
Assert.Equal(0xFF, bytes[107]);
|
||||||
|
Assert.Equal(0xFD, bytes[108]);
|
||||||
|
Assert.Equal((byte)'P', bytes[109]);
|
||||||
|
Assert.Equal((byte)'D', bytes[110]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LeadingDR_LongTravel_IsChunked()
|
||||||
|
{
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(1, 47), new Vector(2, 47) },
|
||||||
|
};
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false }).Write(polylines, ms);
|
||||||
|
|
||||||
|
var bytes = ms.ToArray();
|
||||||
|
Assert.Equal((byte)'D', bytes[95]);
|
||||||
|
Assert.Equal((byte)'R', bytes[96]);
|
||||||
|
Assert.Equal((byte)'P', bytes[125]);
|
||||||
|
Assert.Equal((byte)'D', bytes[126]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OptionsPatchVsVzDz()
|
||||||
|
{
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(0.5, 0) },
|
||||||
|
};
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
new GravographISWriter(new GravographISWriterOptions
|
||||||
|
{
|
||||||
|
DepthInches = 0.125, // 254 steps = 0x00FE
|
||||||
|
FeedMmPerSec = 50, // 0x0032
|
||||||
|
}).Write(polylines, ms);
|
||||||
|
|
||||||
|
var bytes = ms.ToArray();
|
||||||
|
AssertOperand(bytes, (byte)'V', (byte)'S', 0x00, 0x32);
|
||||||
|
AssertOperand(bytes, (byte)'V', (byte)'Z', 0x00, 0x32);
|
||||||
|
AssertOperand(bytes, (byte)'D', (byte)'Z', 0x00, 0xFE);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReturnsToOriginAfterFinalLift_ByDefault()
|
||||||
|
{
|
||||||
|
var polylines = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(1, 0), new Vector(1, -1) },
|
||||||
|
};
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
new GravographISWriter().Write(polylines, ms);
|
||||||
|
|
||||||
|
var bytes = ms.ToArray();
|
||||||
|
var liftIndex = LastIndexOfCommand(bytes, (byte)'P', (byte)'U', 0x00, 0x01);
|
||||||
|
Assert.True(liftIndex >= 0);
|
||||||
|
|
||||||
|
Assert.Equal(0xFF, bytes[liftIndex + 6]);
|
||||||
|
Assert.Equal(0xFD, bytes[liftIndex + 7]);
|
||||||
|
Assert.Equal((byte)'P', bytes[liftIndex + 8]);
|
||||||
|
Assert.Equal((byte)'U', bytes[liftIndex + 9]);
|
||||||
|
|
||||||
|
var dx = ReadInt16(bytes, liftIndex + 16);
|
||||||
|
var dy = ReadInt16(bytes, liftIndex + 18);
|
||||||
|
Assert.Equal(-GravographISWriter.StepsPerInch, dx);
|
||||||
|
Assert.Equal(-GravographISWriter.StepsPerInch, dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertOperand(byte[] bytes, byte c0, byte c1, byte hi, byte lo)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < bytes.Length - 5; i++)
|
||||||
|
{
|
||||||
|
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD && bytes[i + 2] == c0 && bytes[i + 3] == c1)
|
||||||
|
{
|
||||||
|
Assert.Equal(hi, bytes[i + 4]);
|
||||||
|
Assert.Equal(lo, bytes[i + 5]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.Fail($"Command {(char)c0}{(char)c1} not found in stream.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int LastIndexOfCommand(byte[] bytes, byte c0, byte c1, byte hi, byte lo)
|
||||||
|
{
|
||||||
|
for (var i = bytes.Length - 6; i >= 0; i--)
|
||||||
|
{
|
||||||
|
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD &&
|
||||||
|
bytes[i + 2] == c0 && bytes[i + 3] == c1 &&
|
||||||
|
bytes[i + 4] == hi && bytes[i + 5] == lo)
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short ReadInt16(byte[] bytes, int offset)
|
||||||
|
{
|
||||||
|
return unchecked((short)((bytes[offset] << 8) | bytes[offset + 1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static byte[] HexToBytes(string hex)
|
||||||
|
{
|
||||||
|
var clean = hex.Replace(" ", string.Empty).Replace("\n", string.Empty).Replace("\r", string.Empty);
|
||||||
|
var bytes = new byte[clean.Length / 2];
|
||||||
|
for (var i = 0; i < bytes.Length; i++)
|
||||||
|
bytes[i] = System.Convert.ToByte(clean.Substring(i * 2, 2), 16);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using OpenNest;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Posts.GravographIS;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.GravographIS;
|
||||||
|
|
||||||
|
public class NestPolylineExtractorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ExtractPart_IncrementalProgram_ProducesAbsoluteCoordinates()
|
||||||
|
{
|
||||||
|
// 1x1 square in G91 (incremental) mode — the form OpenNest's UI writes
|
||||||
|
// to .nest files. Without absolute-mode handling the extractor plotted
|
||||||
|
// each EndPoint as if it were absolute, producing a 2x2 diamond.
|
||||||
|
var program = new Program(Mode.Incremental);
|
||||||
|
program.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
program.Codes.Add(new LinearMove(1, 0));
|
||||||
|
program.Codes.Add(new LinearMove(0, 1));
|
||||||
|
program.Codes.Add(new LinearMove(-1, 0));
|
||||||
|
program.Codes.Add(new LinearMove(0, -1));
|
||||||
|
|
||||||
|
var drawing = new Drawing("Square 1x1", program);
|
||||||
|
var part = new Part(drawing, new Vector(0.25, 46.75));
|
||||||
|
|
||||||
|
var polylines = new NestPolylineExtractor().ExtractPart(part);
|
||||||
|
|
||||||
|
Assert.Single(polylines);
|
||||||
|
var poly = polylines[0];
|
||||||
|
Assert.Equal(5, poly.Count);
|
||||||
|
Assert.Equal(new Vector(0.25, 46.75), poly[0]);
|
||||||
|
Assert.Equal(new Vector(1.25, 46.75), poly[1]);
|
||||||
|
Assert.Equal(new Vector(1.25, 47.75), poly[2]);
|
||||||
|
Assert.Equal(new Vector(0.25, 47.75), poly[3]);
|
||||||
|
Assert.Equal(new Vector(0.25, 46.75), poly[4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Posts.GravographIS;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.GravographIS;
|
||||||
|
|
||||||
|
public class PolylinePrePassTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Stitch_TwoConnectedSegments_BecomeOnePolyline()
|
||||||
|
{
|
||||||
|
var inputs = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(1, 0) },
|
||||||
|
new[] { new Vector(1, 0), new Vector(1, 1) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var stitched = PolylinePrePass.Stitch(inputs);
|
||||||
|
|
||||||
|
Assert.Single(stitched);
|
||||||
|
Assert.Equal(3, stitched[0].Count);
|
||||||
|
Assert.Equal(new Vector(0, 0), stitched[0][0]);
|
||||||
|
Assert.Equal(new Vector(1, 0), stitched[0][1]);
|
||||||
|
Assert.Equal(new Vector(1, 1), stitched[0][2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stitch_FourSegmentsFormingClosedSquare_BecomeOnePolyline()
|
||||||
|
{
|
||||||
|
var inputs = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(1, 0) },
|
||||||
|
new[] { new Vector(1, 0), new Vector(1, 1) },
|
||||||
|
new[] { new Vector(1, 1), new Vector(0, 1) },
|
||||||
|
new[] { new Vector(0, 1), new Vector(0, 0) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var stitched = PolylinePrePass.Stitch(inputs);
|
||||||
|
|
||||||
|
Assert.Single(stitched);
|
||||||
|
// Four edges + closing return-to-start = five vertices.
|
||||||
|
Assert.Equal(5, stitched[0].Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stitch_ReversesOneSegmentToMakeAJoin()
|
||||||
|
{
|
||||||
|
// Second segment is given backward; stitcher should reverse it.
|
||||||
|
var inputs = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(1, 0) },
|
||||||
|
new[] { new Vector(2, 0), new Vector(1, 0) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var stitched = PolylinePrePass.Stitch(inputs);
|
||||||
|
|
||||||
|
Assert.Single(stitched);
|
||||||
|
Assert.Equal(3, stitched[0].Count);
|
||||||
|
Assert.Equal(new Vector(0, 0), stitched[0][0]);
|
||||||
|
Assert.Equal(new Vector(2, 0), stitched[0][stitched[0].Count - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stitch_DisjointSegments_StayDistinct()
|
||||||
|
{
|
||||||
|
var inputs = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(1, 0) },
|
||||||
|
new[] { new Vector(5, 5), new Vector(6, 5) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var stitched = PolylinePrePass.Stitch(inputs);
|
||||||
|
|
||||||
|
Assert.Equal(2, stitched.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Stitch_DropsZeroAndSinglePointPolylines()
|
||||||
|
{
|
||||||
|
var inputs = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new Vector[] { },
|
||||||
|
new[] { new Vector(0, 0) },
|
||||||
|
new[] { new Vector(0, 0), new Vector(1, 0) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var stitched = PolylinePrePass.Stitch(inputs);
|
||||||
|
|
||||||
|
Assert.Single(stitched);
|
||||||
|
Assert.Equal(2, stitched[0].Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reorder_ReducesTotalPenUpTravelVsWorstCase()
|
||||||
|
{
|
||||||
|
// Three short polylines at (0,0), (10,0), (5,0). The greedy NN starting
|
||||||
|
// from origin should pick (0,0)→(5,0)→(10,0) (travels of 4 + 4 ≈ 8) over
|
||||||
|
// the worst-case input order (0,0)→(10,0)→(5,0) (travels 9 + 4 ≈ 13).
|
||||||
|
var inputs = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(0, 0), new Vector(1, 0) },
|
||||||
|
new[] { new Vector(10, 0), new Vector(11, 0) },
|
||||||
|
new[] { new Vector(5, 0), new Vector(6, 0) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var reordered = PolylinePrePass.Reorder(inputs);
|
||||||
|
|
||||||
|
Assert.Equal(3, reordered.Count);
|
||||||
|
var travelBefore = TotalPenUpTravel(inputs);
|
||||||
|
var travelAfter = TotalPenUpTravel(reordered);
|
||||||
|
Assert.True(travelAfter < travelBefore,
|
||||||
|
$"Expected reorder to reduce pen-up travel; before={travelBefore}, after={travelAfter}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reorder_ReversesPolylineIfTailIsCloser()
|
||||||
|
{
|
||||||
|
// Origin (0,0); a single polyline whose tail is much closer to origin
|
||||||
|
// than its head. Reorder should flip it.
|
||||||
|
var inputs = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(10, 0), new Vector(0.5, 0) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var reordered = PolylinePrePass.Reorder(inputs, allowReverse: true);
|
||||||
|
|
||||||
|
Assert.Single(reordered);
|
||||||
|
Assert.Equal(new Vector(0.5, 0), reordered[0][0]);
|
||||||
|
Assert.Equal(new Vector(10, 0), reordered[0][1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reorder_ReverseDisabled_KeepsDirection()
|
||||||
|
{
|
||||||
|
var inputs = new List<IReadOnlyList<Vector>>
|
||||||
|
{
|
||||||
|
new[] { new Vector(10, 0), new Vector(0.5, 0) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var reordered = PolylinePrePass.Reorder(inputs, allowReverse: false);
|
||||||
|
|
||||||
|
Assert.Single(reordered);
|
||||||
|
Assert.Equal(new Vector(10, 0), reordered[0][0]);
|
||||||
|
Assert.Equal(new Vector(0.5, 0), reordered[0][1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double TotalPenUpTravel(IEnumerable<IReadOnlyList<Vector>> polylines)
|
||||||
|
{
|
||||||
|
var total = 0.0;
|
||||||
|
Vector? last = null;
|
||||||
|
foreach (var p in polylines)
|
||||||
|
{
|
||||||
|
if (p == null || p.Count < 2) continue;
|
||||||
|
if (last.HasValue)
|
||||||
|
{
|
||||||
|
var dx = p[0].X - last.Value.X;
|
||||||
|
var dy = p[0].Y - last.Value.Y;
|
||||||
|
total += System.Math.Sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
last = p[p.Count - 1];
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,21 +134,5 @@ namespace OpenNest.Tests.IO
|
|||||||
Assert.NotNull(drawing.Program);
|
Assert.NotNull(drawing.Program);
|
||||||
Assert.NotNull(drawing.SourceEntities);
|
Assert.NotNull(drawing.SourceEntities);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Import_WhenDetectTitleBlockTrue_PopulatesTitleBlockEntityIds()
|
|
||||||
{
|
|
||||||
var result = CadImporter.Import(TestDxf);
|
|
||||||
|
|
||||||
Assert.NotNull(result.TitleBlockEntityIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Import_WhenDetectTitleBlockFalse_TitleBlockEntityIdsIsNull()
|
|
||||||
{
|
|
||||||
var result = CadImporter.Import(TestDxf, new CadImportOptions { DetectTitleBlock = false });
|
|
||||||
|
|
||||||
Assert.Null(result.TitleBlockEntityIds);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.IO;
|
||||||
|
|
||||||
|
public class ChrFontTests
|
||||||
|
{
|
||||||
|
private ChrFont LoadFont()
|
||||||
|
{
|
||||||
|
var path = TestConfig.GetExistingPath("ChrFontPath");
|
||||||
|
Skip.If(path == null, "ChrFontPath not configured in test-config.json or file not found");
|
||||||
|
return ChrFont.Read(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Read_ParsesFontName()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
Assert.Equal("US BLOCK 1L", font.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Read_ParsesVersion()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
Assert.StartsWith("C1.", font.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Read_HasAsciiGlyphs()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
Assert.True(font.HasGlyph('A'));
|
||||||
|
Assert.True(font.HasGlyph('Z'));
|
||||||
|
Assert.True(font.HasGlyph('0'));
|
||||||
|
Assert.True(font.HasGlyph(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Read_HasExtendedGlyphs()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
Assert.True(font.HasGlyph(0xC7)); // C-cedilla
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Glyph_L_ProducesLines()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var glyph = font.GetGlyph('L');
|
||||||
|
Assert.NotNull(glyph);
|
||||||
|
|
||||||
|
var entities = glyph.ToEntities(1.0, 0, 0);
|
||||||
|
Assert.True(entities.Count >= 2, $"Expected at least 2 entities for 'L', got {entities.Count}");
|
||||||
|
Assert.All(entities, e => Assert.Equal(EntityType.Line, e.Type));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Glyph_O_ProducesEntities()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var glyph = font.GetGlyph('O');
|
||||||
|
Assert.NotNull(glyph);
|
||||||
|
|
||||||
|
var entities = glyph.ToEntities(1.0, 0, 0);
|
||||||
|
Assert.True(entities.Count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void RenderText_ProducesEntities()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var entities = font.RenderText("HELLO", 1.0, new Vector(0, 0));
|
||||||
|
Assert.True(entities.Count > 0, "RenderText should produce entities");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void RenderText_ScalesCorrectly()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
|
||||||
|
var small = font.RenderText("A", 0.5, Vector.Zero);
|
||||||
|
var large = font.RenderText("A", 2.0, Vector.Zero);
|
||||||
|
|
||||||
|
var smallBox = small.GetBoundingBox();
|
||||||
|
var largeBox = large.GetBoundingBox();
|
||||||
|
|
||||||
|
Assert.True(largeBox.Width > smallBox.Width);
|
||||||
|
Assert.True(largeBox.Length > smallBox.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void RenderText_AdvancesCursor()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var abEntities = font.RenderText("AB", 1.0, Vector.Zero);
|
||||||
|
var aEntities = font.RenderText("A", 1.0, Vector.Zero);
|
||||||
|
|
||||||
|
var abBox = abEntities.GetBoundingBox();
|
||||||
|
var aBox = aEntities.GetBoundingBox();
|
||||||
|
|
||||||
|
Assert.True(abBox.Length > aBox.Length * 1.5,
|
||||||
|
$"AB width ({abBox.Length:F1}) should be significantly wider than A width ({aBox.Length:F1})");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void RenderText_MatchesGravographReference()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
|
||||||
|
var height = 5.08;
|
||||||
|
var centerX = 50.8;
|
||||||
|
var centerY = 34.925;
|
||||||
|
|
||||||
|
var entities = font.RenderText("Text", height, Vector.Zero);
|
||||||
|
var rawBox = entities.GetBoundingBox();
|
||||||
|
var shiftX = centerX - (rawBox.Left + rawBox.Right) / 2;
|
||||||
|
var shiftY = centerY - (rawBox.Top + rawBox.Bottom) / 2;
|
||||||
|
foreach (var e in entities)
|
||||||
|
e.Offset(new Vector(shiftX, shiftY));
|
||||||
|
Assert.True(entities.Count > 0, "Should produce entities for 'Text'");
|
||||||
|
|
||||||
|
var box = entities.GetBoundingBox();
|
||||||
|
|
||||||
|
var refLeft = 43.53;
|
||||||
|
var refRight = 58.07;
|
||||||
|
var refBottom = 32.39;
|
||||||
|
var refTop = 37.47;
|
||||||
|
|
||||||
|
var tolerance = 0.5;
|
||||||
|
|
||||||
|
Assert.True(System.Math.Abs(box.Left - refLeft) < tolerance,
|
||||||
|
$"Left: ours={box.Left:F2}, ref={refLeft:F2}, diff={System.Math.Abs(box.Left - refLeft):F2}");
|
||||||
|
Assert.True(System.Math.Abs(box.Right - refRight) < tolerance,
|
||||||
|
$"Right: ours={box.Right:F2}, ref={refRight:F2}, diff={System.Math.Abs(box.Right - refRight):F2}");
|
||||||
|
Assert.True(System.Math.Abs(box.Bottom - refBottom) < tolerance,
|
||||||
|
$"Bottom: ours={box.Bottom:F2}, ref={refBottom:F2}, diff={System.Math.Abs(box.Bottom - refBottom):F2}");
|
||||||
|
Assert.True(System.Math.Abs(box.Top - refTop) < tolerance,
|
||||||
|
$"Top: ours={box.Top:F2}, ref={refTop:F2}, diff={System.Math.Abs(box.Top - refTop):F2}");
|
||||||
|
|
||||||
|
var actualCapHeight = box.Top - box.Bottom;
|
||||||
|
Assert.True(System.Math.Abs(actualCapHeight - height) < 0.5,
|
||||||
|
$"Cap height: ours={actualCapHeight:F2}, expected={height:F2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void MeasureTextWidth_IsConsistent()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var height = 5.08;
|
||||||
|
var measuredWidth = font.MeasureTextWidth("Text", height);
|
||||||
|
var entities = font.RenderText("Text", height, Vector.Zero);
|
||||||
|
var box = entities.GetBoundingBox();
|
||||||
|
|
||||||
|
Assert.True(measuredWidth >= box.Length,
|
||||||
|
$"Measured={measuredWidth:F2} should be >= rendered={box.Length:F2}");
|
||||||
|
Assert.True(measuredWidth - box.Length < 2.0,
|
||||||
|
$"Measured={measuredWidth:F2}, rendered={box.Length:F2}, diff={measuredWidth - box.Length:F2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Glyph_t_HasCurveAtBottom()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var glyph = font.GetGlyph('t');
|
||||||
|
Assert.NotNull(glyph);
|
||||||
|
|
||||||
|
var entities = glyph.ToEntities(1.0, 0, 0);
|
||||||
|
var lines = entities.Cast<Line>().ToList();
|
||||||
|
|
||||||
|
Assert.True(lines.Count >= 10, $"Expected at least 10 entities for 't', got {lines.Count}");
|
||||||
|
|
||||||
|
var curveLines = lines.Skip(1).Take(lines.Count - 3).ToList();
|
||||||
|
Assert.True(curveLines.Count >= 14, $"Expected at least 14 curve segments, got {curveLines.Count}");
|
||||||
|
|
||||||
|
var lastCurve = curveLines[^1];
|
||||||
|
Assert.True(lastCurve.EndPoint.X > curveLines[0].StartPoint.X,
|
||||||
|
$"Curve should end to the right of where it starts: start X={curveLines[0].StartPoint.X:F1}, end X={lastCurve.EndPoint.X:F1}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using CSMath;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.IO;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace OpenNest.Tests.IO
|
|
||||||
{
|
|
||||||
public class TitleBlockDetectorTests
|
|
||||||
{
|
|
||||||
private static Line MakeLine(double x1, double y1, double x2, double y2) =>
|
|
||||||
new Line(x1, y1, x2, y2);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DetectByLayerName_FlagsTitleLayer()
|
|
||||||
{
|
|
||||||
var line = MakeLine(0, 0, 10, 0);
|
|
||||||
line.Layer = new Layer("TITLE");
|
|
||||||
var entities = new List<Entity> { line };
|
|
||||||
|
|
||||||
var result = TitleBlockDetector.Detect(entities, null);
|
|
||||||
|
|
||||||
Assert.Contains(line.Id, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DetectByLayerName_CaseInsensitive()
|
|
||||||
{
|
|
||||||
var line = MakeLine(0, 0, 10, 0);
|
|
||||||
line.Layer = new Layer("border");
|
|
||||||
var entities = new List<Entity> { line };
|
|
||||||
|
|
||||||
var result = TitleBlockDetector.Detect(entities, null);
|
|
||||||
|
|
||||||
Assert.Contains(line.Id, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DetectByLayerName_IgnoresNonMatchingLayers()
|
|
||||||
{
|
|
||||||
var line = MakeLine(0, 0, 10, 0);
|
|
||||||
line.Layer = new Layer("0");
|
|
||||||
var entities = new List<Entity> { line };
|
|
||||||
|
|
||||||
var result = TitleBlockDetector.Detect(entities, null);
|
|
||||||
|
|
||||||
Assert.DoesNotContain(line.Id, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("TITLE")]
|
|
||||||
[InlineData("TITLEBLOCK")]
|
|
||||||
[InlineData("TITLE_BLOCK")]
|
|
||||||
[InlineData("BORDER")]
|
|
||||||
[InlineData("FRAME")]
|
|
||||||
[InlineData("TB")]
|
|
||||||
[InlineData("INFO")]
|
|
||||||
[InlineData("SHEET")]
|
|
||||||
[InlineData("ANNOTATION")]
|
|
||||||
public void DetectByLayerName_AllKnownNames(string layerName)
|
|
||||||
{
|
|
||||||
var line = MakeLine(0, 0, 10, 0);
|
|
||||||
line.Layer = new Layer(layerName);
|
|
||||||
var entities = new List<Entity> { line };
|
|
||||||
|
|
||||||
var result = TitleBlockDetector.Detect(entities, null);
|
|
||||||
|
|
||||||
Assert.Contains(line.Id, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DetectBorder_FlagsLinesOnBoundingBoxEdges()
|
|
||||||
{
|
|
||||||
var entities = new List<Entity>
|
|
||||||
{
|
|
||||||
new Line(0, 0, 86, 0) { Layer = new Layer("0") },
|
|
||||||
new Line(86, 0, 86, 134) { Layer = new Layer("0") },
|
|
||||||
new Line(86, 134, 0, 134) { Layer = new Layer("0") },
|
|
||||||
new Line(0, 134, 0, 0) { Layer = new Layer("0") },
|
|
||||||
new Line(30, 40, 50, 90) { Layer = new Layer("0") },
|
|
||||||
new Line(50, 90, 70, 40) { Layer = new Layer("0") },
|
|
||||||
new Line(70, 40, 30, 40) { Layer = new Layer("0") },
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = TitleBlockDetector.Detect(entities, null);
|
|
||||||
|
|
||||||
Assert.Contains(entities[0].Id, result);
|
|
||||||
Assert.Contains(entities[1].Id, result);
|
|
||||||
Assert.Contains(entities[2].Id, result);
|
|
||||||
Assert.Contains(entities[3].Id, result);
|
|
||||||
Assert.DoesNotContain(entities[4].Id, result);
|
|
||||||
Assert.DoesNotContain(entities[5].Id, result);
|
|
||||||
Assert.DoesNotContain(entities[6].Id, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DetectBorder_FlagsZoneMarkerTicks()
|
|
||||||
{
|
|
||||||
var entities = new List<Entity>
|
|
||||||
{
|
|
||||||
new Line(0, 0, 100, 0) { Layer = new Layer("0") },
|
|
||||||
new Line(100, 0, 100, 80) { Layer = new Layer("0") },
|
|
||||||
new Line(100, 80, 0, 80) { Layer = new Layer("0") },
|
|
||||||
new Line(0, 80, 0, 0) { Layer = new Layer("0") },
|
|
||||||
new Line(25, 80, 25, 77) { Layer = new Layer("0") },
|
|
||||||
new Line(50, 80, 50, 77) { Layer = new Layer("0") },
|
|
||||||
new Line(75, 80, 75, 77) { Layer = new Layer("0") },
|
|
||||||
new Line(40, 30, 60, 30) { Layer = new Layer("0") },
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = TitleBlockDetector.Detect(entities, null);
|
|
||||||
|
|
||||||
Assert.Contains(entities[4].Id, result);
|
|
||||||
Assert.Contains(entities[5].Id, result);
|
|
||||||
Assert.Contains(entities[6].Id, result);
|
|
||||||
Assert.DoesNotContain(entities[7].Id, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DetectBorder_IgnoresWhenNoBorderPresent()
|
|
||||||
{
|
|
||||||
var entities = new List<Entity>
|
|
||||||
{
|
|
||||||
new Line(30, 40, 50, 90) { Layer = new Layer("0") },
|
|
||||||
new Line(50, 90, 70, 40) { Layer = new Layer("0") },
|
|
||||||
new Line(70, 40, 30, 40) { Layer = new Layer("0") },
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = TitleBlockDetector.Detect(entities, null);
|
|
||||||
|
|
||||||
Assert.Empty(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DetectBorder_ToleratesSlightRotation()
|
|
||||||
{
|
|
||||||
var angleRad = OpenNest.Math.Angle.ToRadians(0.5);
|
|
||||||
var endY = 86 * System.Math.Sin(angleRad);
|
|
||||||
var entities = new List<Entity>
|
|
||||||
{
|
|
||||||
new Line(0, 0, 86, endY) { Layer = new Layer("0") },
|
|
||||||
new Line(86, endY, 86, 134) { Layer = new Layer("0") },
|
|
||||||
new Line(86, 134, 0, 134) { Layer = new Layer("0") },
|
|
||||||
new Line(0, 134, 0, 0) { Layer = new Layer("0") },
|
|
||||||
new Line(30, 40, 50, 90) { Layer = new Layer("0") },
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = TitleBlockDetector.Detect(entities, null);
|
|
||||||
|
|
||||||
Assert.Contains(entities[0].Id, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DetectTitleBlock_FlagsEntitiesInTextDenseCorner()
|
|
||||||
{
|
|
||||||
var partLine1 = new Line(5, 70, 25, 120) { Layer = new Layer("0") };
|
|
||||||
var partLine2 = new Line(25, 120, 45, 70) { Layer = new Layer("0") };
|
|
||||||
var partLine3 = new Line(45, 70, 5, 70) { Layer = new Layer("0") };
|
|
||||||
|
|
||||||
var tbLines = new List<Entity>();
|
|
||||||
for (var x = 50; x <= 85; x += 5)
|
|
||||||
tbLines.Add(new Line(x, 0, x, 30) { Layer = new Layer("0") });
|
|
||||||
for (var y = 0; y <= 30; y += 5)
|
|
||||||
tbLines.Add(new Line(50, y, 85, y) { Layer = new Layer("0") });
|
|
||||||
|
|
||||||
var entities = new List<Entity> { partLine1, partLine2, partLine3 };
|
|
||||||
entities.AddRange(tbLines);
|
|
||||||
|
|
||||||
var doc = BuildDocWithTexts(
|
|
||||||
(60, 5, "TITLE: Test Part"),
|
|
||||||
(60, 10, "DWG NO: 12345"),
|
|
||||||
(60, 15, "SCALE: 1:1"),
|
|
||||||
(60, 20, "REV: A"),
|
|
||||||
(60, 25, "MATERIAL: STEEL"));
|
|
||||||
|
|
||||||
var result = TitleBlockDetector.Detect(entities, doc);
|
|
||||||
|
|
||||||
foreach (var tb in tbLines)
|
|
||||||
Assert.Contains(tb.Id, result);
|
|
||||||
Assert.DoesNotContain(partLine1.Id, result);
|
|
||||||
Assert.DoesNotContain(partLine2.Id, result);
|
|
||||||
Assert.DoesNotContain(partLine3.Id, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DetectTitleBlock_NoFalsePositivesWithoutText()
|
|
||||||
{
|
|
||||||
var entities = new List<Entity>
|
|
||||||
{
|
|
||||||
new Line(30, 40, 50, 90) { Layer = new Layer("0") },
|
|
||||||
new Line(50, 90, 70, 40) { Layer = new Layer("0") },
|
|
||||||
new Line(70, 40, 30, 40) { Layer = new Layer("0") },
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = TitleBlockDetector.Detect(entities, null);
|
|
||||||
|
|
||||||
Assert.Empty(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DetectTitleBlock_BottomEdgeStrip()
|
|
||||||
{
|
|
||||||
var partLine = new Line(20, 40, 80, 40) { Layer = new Layer("0") };
|
|
||||||
|
|
||||||
var tbLines = new List<Entity>();
|
|
||||||
for (var x = 0; x <= 100; x += 10)
|
|
||||||
tbLines.Add(new Line(x, 0, x, 20) { Layer = new Layer("0") });
|
|
||||||
for (var y = 0; y <= 20; y += 5)
|
|
||||||
tbLines.Add(new Line(0, y, 100, y) { Layer = new Layer("0") });
|
|
||||||
|
|
||||||
var entities = new List<Entity> { partLine };
|
|
||||||
entities.AddRange(tbLines);
|
|
||||||
|
|
||||||
var doc = BuildDocWithTexts(
|
|
||||||
(10, 5, "TITLE"),
|
|
||||||
(30, 5, "DWG NO"),
|
|
||||||
(50, 5, "SCALE"),
|
|
||||||
(70, 5, "REV"),
|
|
||||||
(90, 5, "DATE"));
|
|
||||||
|
|
||||||
var result = TitleBlockDetector.Detect(entities, doc);
|
|
||||||
|
|
||||||
foreach (var tb in tbLines)
|
|
||||||
Assert.Contains(tb.Id, result);
|
|
||||||
Assert.DoesNotContain(partLine.Id, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ACadSharp.CadDocument BuildDocWithTexts(
|
|
||||||
params (double x, double y, string value)[] texts)
|
|
||||||
{
|
|
||||||
var doc = new ACadSharp.CadDocument();
|
|
||||||
foreach (var (x, y, value) in texts)
|
|
||||||
{
|
|
||||||
var mtext = new ACadSharp.Entities.MText
|
|
||||||
{
|
|
||||||
InsertPoint = new XYZ(x, y, 0),
|
|
||||||
Value = value,
|
|
||||||
Height = 2.0
|
|
||||||
};
|
|
||||||
doc.Entities.Add(mtext);
|
|
||||||
}
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
<PackageReference Include="xunit" Version="2.5.3" />
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
|
<ProjectReference Include="..\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
|
||||||
|
<ProjectReference Include="..\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
|
||||||
<ProjectReference Include="..\OpenNest\OpenNest.csproj" />
|
<ProjectReference Include="..\OpenNest\OpenNest.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -37,6 +39,9 @@
|
|||||||
<Content Include="Splitting\TestData\**\*">
|
<Content Include="Splitting\TestData\**\*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="test-config.json" Condition="Exists('test-config.json')">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,8 +1,37 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Tests;
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
|
internal static class TestConfig
|
||||||
|
{
|
||||||
|
private static readonly Lazy<Dictionary<string, string>> Config = new(() =>
|
||||||
|
{
|
||||||
|
var dir = AppContext.BaseDirectory;
|
||||||
|
for (var i = 0; i < 6; i++)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(dir, "test-config.json");
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
return JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new();
|
||||||
|
}
|
||||||
|
dir = Path.GetDirectoryName(dir)!;
|
||||||
|
}
|
||||||
|
return new();
|
||||||
|
});
|
||||||
|
|
||||||
|
public static string? Get(string key) =>
|
||||||
|
Config.Value.TryGetValue(key, out var val) ? val : null;
|
||||||
|
|
||||||
|
public static string? GetExistingPath(string key)
|
||||||
|
{
|
||||||
|
var path = Get(key);
|
||||||
|
return path != null && File.Exists(path) ? path : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static class TestHelpers
|
internal static class TestHelpers
|
||||||
{
|
{
|
||||||
public static Part MakePartAt(double x, double y, double size = 1)
|
public static Part MakePartAt(double x, double y, double size = 1)
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PostProcessors", "PostProce
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.Cincinnati", "OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj", "{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.Cincinnati", "OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj", "{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.GravographIS", "OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj", "{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}"
|
||||||
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
@@ -186,12 +188,25 @@ Global
|
|||||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = Release|Any CPU
|
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
|
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
|
||||||
|
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {86FE17B3-F764-40AE-BCAA-F26B470CA05C}
|
SolutionGuid = {86FE17B3-F764-40AE-BCAA-F26B470CA05C}
|
||||||
|
|||||||
@@ -30,19 +30,17 @@ namespace OpenNest.Controls
|
|||||||
public List<Entity> OriginalEntities { get; set; }
|
public List<Entity> OriginalEntities { get; set; }
|
||||||
public bool ShowEntityLabels { get; set; }
|
public bool ShowEntityLabels { get; set; }
|
||||||
public List<CadText> Texts { get; set; } = new List<CadText>();
|
public List<CadText> Texts { get; set; } = new List<CadText>();
|
||||||
public HashSet<Guid> TitleBlockEntityIds { get; set; }
|
|
||||||
|
|
||||||
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
|
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
|
||||||
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
|
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
|
||||||
private readonly Dictionary<int, Pen> ghostPenCache = new Dictionary<int, Pen>();
|
|
||||||
private readonly Font labelFont = new Font("Segoe UI", 7f);
|
private readonly Font labelFont = new Font("Segoe UI", 7f);
|
||||||
private readonly SolidBrush labelBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
|
private readonly SolidBrush labelBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
|
||||||
private readonly SolidBrush labelBackBrush = new SolidBrush(Color.FromArgb(33, 40, 48));
|
private readonly SolidBrush labelBackBrush = new SolidBrush(Color.FromArgb(33, 40, 48));
|
||||||
private readonly SolidBrush textBrush = new SolidBrush(Color.FromArgb(180, 200, 200, 200));
|
private readonly SolidBrush textBrush = new SolidBrush(Color.FromArgb(180, 200, 200, 200));
|
||||||
private readonly SolidBrush ghostTextBrush = new SolidBrush(Color.FromArgb(50, 200, 200, 200));
|
|
||||||
|
|
||||||
public event EventHandler<Line> LinePicked;
|
public event EventHandler<Line> LinePicked;
|
||||||
public event EventHandler PickCancelled;
|
public event EventHandler PickCancelled;
|
||||||
|
public event EventHandler<CadText> TextConvertRequested;
|
||||||
|
|
||||||
private bool isPickingBendLine;
|
private bool isPickingBendLine;
|
||||||
public bool IsPickingBendLine
|
public bool IsPickingBendLine
|
||||||
@@ -79,6 +77,13 @@ namespace OpenNest.Controls
|
|||||||
if (line != null)
|
if (line != null)
|
||||||
LinePicked?.Invoke(this, line);
|
LinePicked?.Invoke(this, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.Button == MouseButtons.Right)
|
||||||
|
{
|
||||||
|
var text = HitTestText(e.Location);
|
||||||
|
if (text != null)
|
||||||
|
ShowTextContextMenu(text, e.Location);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnPaint(PaintEventArgs e)
|
protected override void OnPaint(PaintEventArgs e)
|
||||||
@@ -105,13 +110,6 @@ namespace OpenNest.Controls
|
|||||||
foreach (var entity in Entities)
|
foreach (var entity in Entities)
|
||||||
{
|
{
|
||||||
if (IsEtchLayer(entity.Layer)) continue;
|
if (IsEtchLayer(entity.Layer)) continue;
|
||||||
|
|
||||||
if (TitleBlockEntityIds != null && TitleBlockEntityIds.Contains(entity.Id))
|
|
||||||
{
|
|
||||||
DrawGhostEntity(e.Graphics, entity);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var isHighlighted = simplifierHighlightSet != null && simplifierHighlightSet.Contains(entity);
|
var isHighlighted = simplifierHighlightSet != null && simplifierHighlightSet.Contains(entity);
|
||||||
var pen = isHighlighted
|
var pen = isHighlighted
|
||||||
? GetEntityPen(Color.FromArgb(60, entity.Color))
|
? GetEntityPen(Color.FromArgb(60, entity.Color))
|
||||||
@@ -253,26 +251,11 @@ namespace OpenNest.Controls
|
|||||||
return pen;
|
return pen;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Pen GetGhostPen(Color color)
|
|
||||||
{
|
|
||||||
var ghostColor = Color.FromArgb(60, color.R, color.G, color.B);
|
|
||||||
var argb = ghostColor.ToArgb();
|
|
||||||
if (!ghostPenCache.TryGetValue(argb, out var pen))
|
|
||||||
{
|
|
||||||
pen = new Pen(ghostColor);
|
|
||||||
ghostPenCache[argb] = pen;
|
|
||||||
}
|
|
||||||
return pen;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ClearPenCache()
|
public void ClearPenCache()
|
||||||
{
|
{
|
||||||
foreach (var pen in penCache.Values)
|
foreach (var pen in penCache.Values)
|
||||||
pen.Dispose();
|
pen.Dispose();
|
||||||
penCache.Clear();
|
penCache.Clear();
|
||||||
foreach (var pen in ghostPenCache.Values)
|
|
||||||
pen.Dispose();
|
|
||||||
ghostPenCache.Clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsEtchLayer(Layer layer) =>
|
private static bool IsEtchLayer(Layer layer) =>
|
||||||
@@ -353,6 +336,41 @@ namespace OpenNest.Controls
|
|||||||
return bestLine;
|
return bestLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CadText HitTestText(Point controlPoint)
|
||||||
|
{
|
||||||
|
if (Texts == null || Texts.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var worldPoint = PointControlToWorld(controlPoint);
|
||||||
|
var tolerance = LengthGuiToWorld(8);
|
||||||
|
|
||||||
|
foreach (var text in Texts)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text.Value))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var estimatedWidth = text.Height * text.Value.Length * 0.6;
|
||||||
|
var minX = text.Position.X - tolerance;
|
||||||
|
var maxX = text.Position.X + estimatedWidth + tolerance;
|
||||||
|
var minY = text.Position.Y - tolerance;
|
||||||
|
var maxY = text.Position.Y + text.Height + tolerance;
|
||||||
|
|
||||||
|
if (worldPoint.X >= minX && worldPoint.X <= maxX &&
|
||||||
|
worldPoint.Y >= minY && worldPoint.Y <= maxY)
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowTextContextMenu(CadText text, Point location)
|
||||||
|
{
|
||||||
|
var menu = new ContextMenuStrip();
|
||||||
|
var item = menu.Items.Add($"Convert \"{text.Value}\" to Geometry");
|
||||||
|
item.Click += (s, e) => TextConvertRequested?.Invoke(this, text);
|
||||||
|
menu.Show(this, location);
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawEntityLabels(Graphics g)
|
private void DrawEntityLabels(Graphics g)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < Entities.Count; i++)
|
for (var i = 0; i < Entities.Count; i++)
|
||||||
@@ -438,28 +456,10 @@ namespace OpenNest.Controls
|
|||||||
labelBrush.Dispose();
|
labelBrush.Dispose();
|
||||||
labelBackBrush.Dispose();
|
labelBackBrush.Dispose();
|
||||||
textBrush.Dispose();
|
textBrush.Dispose();
|
||||||
ghostTextBrush.Dispose();
|
|
||||||
}
|
}
|
||||||
base.Dispose(disposing);
|
base.Dispose(disposing);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawGhostEntity(Graphics g, Entity e)
|
|
||||||
{
|
|
||||||
var pen = GetGhostPen(e.Color);
|
|
||||||
switch (e.Type)
|
|
||||||
{
|
|
||||||
case EntityType.Arc:
|
|
||||||
DrawArc(g, (Arc)e, pen);
|
|
||||||
break;
|
|
||||||
case EntityType.Circle:
|
|
||||||
DrawCircle(g, (Circle)e, pen);
|
|
||||||
break;
|
|
||||||
case EntityType.Line:
|
|
||||||
DrawLine(g, (Line)e, pen);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DrawEntity(Graphics g, Entity e, Pen pen)
|
private void DrawEntity(Graphics g, Entity e, Pen pen)
|
||||||
{
|
{
|
||||||
if (!e.Layer.IsVisible || !e.IsVisible)
|
if (!e.Layer.IsVisible || !e.IsVisible)
|
||||||
@@ -544,10 +544,8 @@ namespace OpenNest.Controls
|
|||||||
sf.Alignment = text.HAlign;
|
sf.Alignment = text.HAlign;
|
||||||
sf.LineAlignment = text.VAlign;
|
sf.LineAlignment = text.VAlign;
|
||||||
|
|
||||||
var brush = TitleBlockEntityIds != null && TitleBlockEntityIds.Count > 0
|
|
||||||
? ghostTextBrush : textBrush;
|
|
||||||
using var font = new Font("Segoe UI", fontSize, GraphicsUnit.Pixel);
|
using var font = new Font("Segoe UI", fontSize, GraphicsUnit.Pixel);
|
||||||
g.DrawString(text.Value, font, brush, 0, 0, sf);
|
g.DrawString(text.Value, font, textBrush, 0, 0, sf);
|
||||||
g.Restore(state);
|
g.Restore(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ namespace OpenNest.Controls
|
|||||||
public List<Entity> OriginalEntities { get; set; }
|
public List<Entity> OriginalEntities { get; set; }
|
||||||
public List<Bend> Bends { get; set; } = new();
|
public List<Bend> Bends { get; set; } = new();
|
||||||
public HashSet<Guid> SuppressedEntityIds { get; set; }
|
public HashSet<Guid> SuppressedEntityIds { get; set; }
|
||||||
public HashSet<Guid> TitleBlockEntityIds { get; set; }
|
|
||||||
public Box Bounds { get; set; }
|
public Box Bounds { get; set; }
|
||||||
public int EntityCount { get; set; }
|
public int EntityCount { get; set; }
|
||||||
public List<CadText> Texts { get; set; } = new();
|
public List<CadText> Texts { get; set; } = new();
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
public BestFitResult SelectedResult { get; private set; }
|
public BestFitResult SelectedResult { get; private set; }
|
||||||
public Drawing SelectedDrawing => activeDrawing;
|
public Drawing SelectedDrawing => activeDrawing;
|
||||||
|
public List<Part> SelectedParts { get; private set; }
|
||||||
|
|
||||||
public BestFitViewerForm(DrawingCollection drawings, Plate plate, Units units = Units.Inches)
|
public BestFitViewerForm(DrawingCollection drawings, Plate plate, Units units = Units.Inches)
|
||||||
{
|
{
|
||||||
@@ -318,12 +319,12 @@ namespace OpenNest.Forms
|
|||||||
var cell = new BestFitCell(colorScheme);
|
var cell = new BestFitCell(colorScheme);
|
||||||
cell.PartColor = partColor;
|
cell.PartColor = partColor;
|
||||||
cell.Dock = DockStyle.Fill;
|
cell.Dock = DockStyle.Fill;
|
||||||
|
|
||||||
|
var parts = result.BuildCanonicalParts();
|
||||||
cell.Plate.Size = new Geometry.Size(
|
cell.Plate.Size = new Geometry.Size(
|
||||||
result.BoundingHeight,
|
result.BoundingHeight,
|
||||||
result.BoundingWidth);
|
result.BoundingWidth);
|
||||||
|
|
||||||
var parts = result.BuildParts(drawing);
|
|
||||||
|
|
||||||
foreach (var part in parts)
|
foreach (var part in parts)
|
||||||
cell.Plate.Parts.Add(part);
|
cell.Plate.Parts.Add(part);
|
||||||
|
|
||||||
@@ -332,6 +333,7 @@ namespace OpenNest.Forms
|
|||||||
cell.DoubleClick += (sender, e) =>
|
cell.DoubleClick += (sender, e) =>
|
||||||
{
|
{
|
||||||
SelectedResult = result;
|
SelectedResult = result;
|
||||||
|
SelectedParts = result.BuildSourceParts(drawing);
|
||||||
DialogResult = DialogResult.OK;
|
DialogResult = DialogResult.OK;
|
||||||
Close();
|
Close();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ namespace OpenNest.Forms
|
|||||||
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
|
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
|
||||||
entityView1.LinePicked += OnLinePicked;
|
entityView1.LinePicked += OnLinePicked;
|
||||||
entityView1.PickCancelled += OnPickCancelled;
|
entityView1.PickCancelled += OnPickCancelled;
|
||||||
|
entityView1.TextConvertRequested += OnTextConvertRequested;
|
||||||
btnSplit.Click += OnSplitClicked;
|
btnSplit.Click += OnSplitClicked;
|
||||||
numQuantity.ValueChanged += OnQuantityChanged;
|
numQuantity.ValueChanged += OnQuantityChanged;
|
||||||
txtCustomer.TextChanged += OnCustomerChanged;
|
txtCustomer.TextChanged += OnCustomerChanged;
|
||||||
@@ -94,16 +95,8 @@ namespace OpenNest.Forms
|
|||||||
Bounds = result.Bounds,
|
Bounds = result.Bounds,
|
||||||
EntityCount = result.Entities.Count,
|
EntityCount = result.Entities.Count,
|
||||||
Texts = ExtractTexts(result.Document),
|
Texts = ExtractTexts(result.Document),
|
||||||
TitleBlockEntityIds = result.TitleBlockEntityIds,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (result.TitleBlockEntityIds != null && result.TitleBlockEntityIds.Count > 0)
|
|
||||||
{
|
|
||||||
item.SuppressedEntityIds ??= new HashSet<Guid>();
|
|
||||||
foreach (var id in result.TitleBlockEntityIds)
|
|
||||||
item.SuppressedEntityIds.Add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (InvokeRequired)
|
if (InvokeRequired)
|
||||||
BeginInvoke((Action)(() => fileList.AddItem(item)));
|
BeginInvoke((Action)(() => fileList.AddItem(item)));
|
||||||
else
|
else
|
||||||
@@ -162,7 +155,6 @@ namespace OpenNest.Forms
|
|||||||
entityView1.Entities.AddRange(item.Entities);
|
entityView1.Entities.AddRange(item.Entities);
|
||||||
entityView1.Bends = item.Bends ?? new List<Bend>();
|
entityView1.Bends = item.Bends ?? new List<Bend>();
|
||||||
entityView1.Texts = item.Texts ?? new List<CadText>();
|
entityView1.Texts = item.Texts ?? new List<CadText>();
|
||||||
entityView1.TitleBlockEntityIds = item.TitleBlockEntityIds;
|
|
||||||
|
|
||||||
item.Entities.ForEach(e => e.IsVisible = true);
|
item.Entities.ForEach(e => e.IsVisible = true);
|
||||||
if (item.Entities.Any(e => e.Layer != null))
|
if (item.Entities.Any(e => e.Layer != null))
|
||||||
@@ -472,6 +464,115 @@ namespace OpenNest.Forms
|
|||||||
filterPanel.SetPickMode(false);
|
filterPanel.SetPickMode(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnTextConvertRequested(object sender, Controls.CadText text)
|
||||||
|
{
|
||||||
|
var item = CurrentItem;
|
||||||
|
if (item == null) return;
|
||||||
|
|
||||||
|
var font = LoadChrFont();
|
||||||
|
if (font == null) return;
|
||||||
|
|
||||||
|
var layer = new Geometry.Layer("ENGRAVE")
|
||||||
|
{
|
||||||
|
Color = System.Drawing.Color.Cyan,
|
||||||
|
IsVisible = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var entities = font.RenderText(text.Value, text.Height, Geometry.Vector.Zero, layer);
|
||||||
|
if (entities.Count > 0)
|
||||||
|
{
|
||||||
|
var box = entities.GetBoundingBox();
|
||||||
|
var shiftX = text.HAlign switch
|
||||||
|
{
|
||||||
|
System.Drawing.StringAlignment.Center => text.Position.X - (box.Left + box.Right) / 2,
|
||||||
|
System.Drawing.StringAlignment.Far => text.Position.X - box.Right,
|
||||||
|
_ => text.Position.X - box.Left,
|
||||||
|
};
|
||||||
|
var shiftY = text.VAlign switch
|
||||||
|
{
|
||||||
|
System.Drawing.StringAlignment.Center => text.Position.Y - (box.Top + box.Bottom) / 2,
|
||||||
|
System.Drawing.StringAlignment.Near => text.Position.Y - box.Top,
|
||||||
|
_ => text.Position.Y - box.Bottom,
|
||||||
|
};
|
||||||
|
var shift = new Geometry.Vector(shiftX, shiftY);
|
||||||
|
foreach (var e in entities)
|
||||||
|
e.Offset(shift);
|
||||||
|
}
|
||||||
|
if (entities.Count == 0)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"No geometry produced for \"{text.Value}\".", "Convert Text",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Entities.AddRange(entities);
|
||||||
|
item.Texts.Remove(text);
|
||||||
|
item.EntityCount = item.Entities.Count;
|
||||||
|
item.Bounds = item.Entities.GetBoundingBox();
|
||||||
|
|
||||||
|
entityView1.Entities.Clear();
|
||||||
|
entityView1.Entities.AddRange(item.Entities);
|
||||||
|
entityView1.Texts = item.Texts;
|
||||||
|
filterPanel.LoadItem(item.Entities, item.Bends);
|
||||||
|
entityView1.Invalidate();
|
||||||
|
staleProgram = true;
|
||||||
|
lblEntityCount.Text = $"{item.EntityCount} entities";
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChrFont cachedChrFont;
|
||||||
|
private string cachedChrFontPath;
|
||||||
|
|
||||||
|
private ChrFont LoadChrFont()
|
||||||
|
{
|
||||||
|
if (cachedChrFont != null)
|
||||||
|
return cachedChrFont;
|
||||||
|
|
||||||
|
// Look for .CHR files next to the app, then prompt
|
||||||
|
var appDir = System.IO.Path.GetDirectoryName(Application.ExecutablePath);
|
||||||
|
var candidates = Directory.GetFiles(appDir, "*.CHR", SearchOption.TopDirectoryOnly);
|
||||||
|
|
||||||
|
string fontPath;
|
||||||
|
if (candidates.Length == 1)
|
||||||
|
{
|
||||||
|
fontPath = candidates[0];
|
||||||
|
}
|
||||||
|
else if (candidates.Length > 1)
|
||||||
|
{
|
||||||
|
fontPath = PromptForChrFile(appDir);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fontPath = PromptForChrFile(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fontPath == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cachedChrFont = ChrFont.Read(fontPath);
|
||||||
|
cachedChrFontPath = fontPath;
|
||||||
|
return cachedChrFont;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Error loading font: {ex.Message}", "Font Error",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PromptForChrFile(string initialDir)
|
||||||
|
{
|
||||||
|
using var dlg = new OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = "Select Engraving Font (.CHR)",
|
||||||
|
Filter = "Gravograph Font (*.CHR)|*.CHR",
|
||||||
|
InitialDirectory = initialDir ?? "",
|
||||||
|
};
|
||||||
|
return dlg.ShowDialog() == DialogResult.OK ? dlg.FileName : null;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDragEnter(object sender, DragEventArgs e)
|
private void OnDragEnter(object sender, DragEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||||
@@ -848,6 +949,13 @@ namespace OpenNest.Forms
|
|||||||
ACadSharp.Entities.TextHorizontalAlignment.Right => System.Drawing.StringAlignment.Far,
|
ACadSharp.Entities.TextHorizontalAlignment.Right => System.Drawing.StringAlignment.Far,
|
||||||
_ => System.Drawing.StringAlignment.Near,
|
_ => System.Drawing.StringAlignment.Near,
|
||||||
};
|
};
|
||||||
|
var va = text.VerticalAlignment switch
|
||||||
|
{
|
||||||
|
ACadSharp.Entities.TextVerticalAlignmentType.Middle => System.Drawing.StringAlignment.Center,
|
||||||
|
ACadSharp.Entities.TextVerticalAlignmentType.Top => System.Drawing.StringAlignment.Near,
|
||||||
|
ACadSharp.Entities.TextVerticalAlignmentType.Bottom => System.Drawing.StringAlignment.Far,
|
||||||
|
_ => System.Drawing.StringAlignment.Far,
|
||||||
|
};
|
||||||
texts.Add(new CadText
|
texts.Add(new CadText
|
||||||
{
|
{
|
||||||
Position = new Vector(pt.X, pt.Y),
|
Position = new Vector(pt.X, pt.Y),
|
||||||
@@ -856,6 +964,7 @@ namespace OpenNest.Forms
|
|||||||
Rotation = text.Rotation,
|
Rotation = text.Rotation,
|
||||||
LayerName = text.Layer?.Name,
|
LayerName = text.Layer?.Name,
|
||||||
HAlign = ha,
|
HAlign = ha,
|
||||||
|
VAlign = va,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -686,7 +686,8 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
if (form.ShowDialog(this) == DialogResult.OK && form.SelectedResult != null)
|
if (form.ShowDialog(this) == DialogResult.OK && form.SelectedResult != null)
|
||||||
{
|
{
|
||||||
var parts = form.SelectedResult.BuildParts(form.SelectedDrawing);
|
var parts = form.SelectedParts
|
||||||
|
?? form.SelectedResult.BuildSourceParts(form.SelectedDrawing);
|
||||||
activeForm.PlateView.SetAction(typeof(ActionClone), parts);
|
activeForm.PlateView.SetAction(typeof(ActionClone), parts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Ports;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
|
if (args.Length < 2)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("Usage:");
|
||||||
|
Console.Error.WriteLine(" StreamGravographJob <file.prn> <COMx> [chunk=256] [flow=rtscts|xonxoff|none]");
|
||||||
|
Console.Error.WriteLine(" StreamGravographJob --gen <name> <outfile.prn> # name: testA | testB | miniB | miniSquare");
|
||||||
|
Console.Error.WriteLine(" StreamGravographJob --inspect-nest <file.nest>");
|
||||||
|
Console.Error.WriteLine(" StreamGravographJob --from-nest <file.nest> <outfile.prn>");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inspect a .nest file: extract polylines via the post-processor pipeline and
|
||||||
|
// report dimensions / bounding box / pen-up travel — no bytes written.
|
||||||
|
if (args[0] == "--inspect-nest")
|
||||||
|
{
|
||||||
|
if (args.Length < 2) { Console.Error.WriteLine("--inspect-nest requires <file.nest>"); return 2; }
|
||||||
|
var nestPath = args[1];
|
||||||
|
if (!File.Exists(nestPath)) { Console.Error.WriteLine($"Not found: {nestPath}"); return 3; }
|
||||||
|
|
||||||
|
using var fs = new FileStream(nestPath, FileMode.Open, FileAccess.Read);
|
||||||
|
var reader = new OpenNest.IO.NestReader(fs);
|
||||||
|
var nest = reader.Read();
|
||||||
|
|
||||||
|
Console.WriteLine($"Nest: {nest.Name}");
|
||||||
|
Console.WriteLine($"Units: {nest.Units}");
|
||||||
|
Console.WriteLine($"Plates: {nest.Plates.Count}");
|
||||||
|
var plateIdx = 0;
|
||||||
|
foreach (var plate in nest.Plates)
|
||||||
|
{
|
||||||
|
plateIdx++;
|
||||||
|
Console.WriteLine($" Plate {plateIdx}: size={plate.Size.Length} x {plate.Size.Width}, quadrant={plate.Quadrant}, parts={plate.Parts.Count}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var polylines = new OpenNest.Posts.GravographIS.NestPolylineExtractor().Extract(nest);
|
||||||
|
if (polylines.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("No polylines extracted.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double minX = double.PositiveInfinity, minY = double.PositiveInfinity;
|
||||||
|
double maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
|
||||||
|
int totalPts = 0;
|
||||||
|
foreach (var p in polylines)
|
||||||
|
{
|
||||||
|
foreach (var v in p)
|
||||||
|
{
|
||||||
|
if (v.X < minX) minX = v.X;
|
||||||
|
if (v.X > maxX) maxX = v.X;
|
||||||
|
if (v.Y < minY) minY = v.Y;
|
||||||
|
if (v.Y > maxY) maxY = v.Y;
|
||||||
|
}
|
||||||
|
totalPts += p.Count;
|
||||||
|
}
|
||||||
|
Console.WriteLine($"Polylines: {polylines.Count}, total points: {totalPts}");
|
||||||
|
Console.WriteLine($"Bounding box (inches): X ∈ [{minX:F3}, {maxX:F3}] Y ∈ [{minY:F3}, {maxY:F3}]");
|
||||||
|
Console.WriteLine($"Extents: {maxX - minX:F3}\" × {maxY - minY:F3}\"");
|
||||||
|
|
||||||
|
// After running the pre-pass (stitch + reorder from origin) — what the writer will actually consume.
|
||||||
|
var prepared = OpenNest.Posts.GravographIS.PolylinePrePass.Prepare(polylines);
|
||||||
|
Console.WriteLine($"After stitch+reorder: {prepared.Count} polylines");
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("--- Vertex dump (prepared, upper-left origin, with segment deltas) ---");
|
||||||
|
var pi = 0;
|
||||||
|
foreach (var poly in prepared)
|
||||||
|
{
|
||||||
|
pi++;
|
||||||
|
Console.WriteLine($"Polyline {pi}: {poly.Count} points");
|
||||||
|
var cumX = 0.0; var cumY = 0.0;
|
||||||
|
for (var i = 0; i < poly.Count; i++)
|
||||||
|
{
|
||||||
|
var v = poly[i];
|
||||||
|
if (i == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" [{i}] ({v.X,7:F3}, {v.Y,7:F3}) first DR travel from upper-left origin=({v.X,+7:F3}, {v.Y,+7:F3})");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var dx = v.X - poly[i - 1].X;
|
||||||
|
var dy = v.Y - poly[i - 1].Y;
|
||||||
|
cumX += dx;
|
||||||
|
cumY += dy;
|
||||||
|
Console.WriteLine($" [{i}] ({v.X,7:F3}, {v.Y,7:F3}) Δ=({dx,+7:F3}, {dy,+7:F3}) cum from origin=({cumX,+7:F3}, {cumY,+7:F3})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a .nest file to a .prn job via the full post-processor pipeline.
|
||||||
|
if (args[0] == "--from-nest")
|
||||||
|
{
|
||||||
|
if (args.Length < 3) { Console.Error.WriteLine("--from-nest requires <file.nest> <outfile.prn>"); return 2; }
|
||||||
|
var nestPath = args[1];
|
||||||
|
var outFile = args[2];
|
||||||
|
if (!File.Exists(nestPath)) { Console.Error.WriteLine($"Not found: {nestPath}"); return 3; }
|
||||||
|
|
||||||
|
using var fs = new FileStream(nestPath, FileMode.Open, FileAccess.Read);
|
||||||
|
var nest = new OpenNest.IO.NestReader(fs).Read();
|
||||||
|
var post = new OpenNest.Posts.GravographIS.GravographISPostProcessor();
|
||||||
|
post.Post(nest, outFile);
|
||||||
|
|
||||||
|
var size = new FileInfo(outFile).Length;
|
||||||
|
Console.WriteLine($"Wrote {size} bytes → {outFile}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generator mode: run the live writer to produce a captured-test file on disk.
|
||||||
|
if (args[0] == "--gen")
|
||||||
|
{
|
||||||
|
if (args.Length < 3) { Console.Error.WriteLine("--gen requires <name> <outfile>"); return 2; }
|
||||||
|
var preset = args[1];
|
||||||
|
var outFile = args[2];
|
||||||
|
var polylines = preset.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"testa" => new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
|
||||||
|
{
|
||||||
|
new[] { new OpenNest.Geometry.Vector(1, 1), new OpenNest.Geometry.Vector(1, 3) },
|
||||||
|
},
|
||||||
|
"testb" => new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
|
||||||
|
{
|
||||||
|
new[] { new OpenNest.Geometry.Vector(1, 1), new OpenNest.Geometry.Vector(1, 3) },
|
||||||
|
new[] { new OpenNest.Geometry.Vector(4, 1), new OpenNest.Geometry.Vector(4, 3) },
|
||||||
|
new[] { new OpenNest.Geometry.Vector(4, 5), new OpenNest.Geometry.Vector(4, 7) },
|
||||||
|
new[] { new OpenNest.Geometry.Vector(1, 5), new OpenNest.Geometry.Vector(1, 7) },
|
||||||
|
},
|
||||||
|
// Same 4-polyline topology as testB (vertical lines + diagonal PU travels between them),
|
||||||
|
// shrunk to a 0.5" × 1.5" footprint so it stays right near the operator-set work origin.
|
||||||
|
"minib" => new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
|
||||||
|
{
|
||||||
|
new[] { new OpenNest.Geometry.Vector(0, 0), new OpenNest.Geometry.Vector(0, 0.5) },
|
||||||
|
new[] { new OpenNest.Geometry.Vector(0.5, 0), new OpenNest.Geometry.Vector(0.5, 0.5) },
|
||||||
|
new[] { new OpenNest.Geometry.Vector(0.5, 1), new OpenNest.Geometry.Vector(0.5, 1.5) },
|
||||||
|
new[] { new OpenNest.Geometry.Vector(0, 1), new OpenNest.Geometry.Vector(0, 1.5) },
|
||||||
|
},
|
||||||
|
// Closed 0.5" square as a SINGLE polyline of 5 points → 4-segment PD packet.
|
||||||
|
// Exercises multi-segment PD (one FF FD 50 44 00 00 followed by 4 records,
|
||||||
|
// no intermediate lifts) and bi-directional motion (X+, Y+, X−, Y−).
|
||||||
|
// Returns the head to its starting point so no manual jog needed after.
|
||||||
|
"minisquare" => new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
|
||||||
|
{
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new OpenNest.Geometry.Vector(0, 0),
|
||||||
|
new OpenNest.Geometry.Vector(0.5, 0),
|
||||||
|
new OpenNest.Geometry.Vector(0.5, 0.5),
|
||||||
|
new OpenNest.Geometry.Vector(0, 0.5),
|
||||||
|
new OpenNest.Geometry.Vector(0, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_ => throw new ArgumentException($"Unknown preset '{preset}' (try testA, testB, miniB, or miniSquare)."),
|
||||||
|
};
|
||||||
|
|
||||||
|
using var outFs = new FileStream(outFile, FileMode.Create, FileAccess.Write);
|
||||||
|
new OpenNest.Posts.GravographIS.GravographISWriter().Write(polylines, outFs);
|
||||||
|
Console.WriteLine($"Wrote {new FileInfo(outFile).Length} bytes via live writer → {outFile}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var file = args[0];
|
||||||
|
var portName = args[1];
|
||||||
|
var chunk = args.Length > 2 ? int.Parse(args[2]) : 256;
|
||||||
|
var flowArg = args.Length > 3 ? args[3] : "rtscts";
|
||||||
|
|
||||||
|
var handshake = flowArg.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"rtscts" or "rts" or "cts" => Handshake.RequestToSend,
|
||||||
|
"xonxoff" or "xon" or "xoff" => Handshake.XOnXOff,
|
||||||
|
"none" => Handshake.None,
|
||||||
|
_ => throw new ArgumentException($"Unknown flow control '{flowArg}'."),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!File.Exists(file))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"File not found: {file}");
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = File.ReadAllBytes(file);
|
||||||
|
Console.WriteLine($"File: {file}");
|
||||||
|
Console.WriteLine($"Size: {bytes.Length} bytes");
|
||||||
|
Console.WriteLine($"Header: {BitConverter.ToString(bytes, 0, Math.Min(7, bytes.Length)).Replace('-', ' ')}");
|
||||||
|
|
||||||
|
var ports = SerialPort.GetPortNames();
|
||||||
|
Array.Sort(ports);
|
||||||
|
Console.WriteLine($"Available COM ports: {string.Join(", ", ports)}");
|
||||||
|
if (Array.IndexOf(ports, portName) < 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"{portName} not in available ports.");
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var port = new SerialPort(portName, 9600, Parity.None, 8, StopBits.One)
|
||||||
|
{
|
||||||
|
Handshake = handshake,
|
||||||
|
WriteTimeout = 30000,
|
||||||
|
ReadTimeout = 30000,
|
||||||
|
WriteBufferSize = 4096,
|
||||||
|
DtrEnable = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Probe with the same CreateFile flags SerialStream uses, in this same process,
|
||||||
|
// so we can tell SerialStream-specific failures apart from process-level access denials.
|
||||||
|
{
|
||||||
|
const uint GENERIC_RW = 0x80000000u | 0x40000000u;
|
||||||
|
const uint OPEN_EXISTING = 3;
|
||||||
|
const uint FILE_FLAG_OVERLAPPED = 0x40000000u;
|
||||||
|
var devName = @"\\.\" + portName;
|
||||||
|
var handle = NativeMethods.CreateFileW(devName, GENERIC_RW, 0, IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, IntPtr.Zero);
|
||||||
|
var err = Marshal.GetLastWin32Error();
|
||||||
|
if (handle.IsInvalid)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"CreateFile(\"{devName}\", overlapped, exclusive) FAILED: win32={err} ({new Win32Exception(err).Message})");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine($"CreateFile(\"{devName}\", overlapped, exclusive) OK — closing.");
|
||||||
|
handle.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"Opening {portName} 9600 8N1 handshake={handshake}...");
|
||||||
|
port.Open();
|
||||||
|
Console.WriteLine("Opened.");
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var i = 0; i < bytes.Length; i += chunk)
|
||||||
|
{
|
||||||
|
var n = Math.Min(chunk, bytes.Length - i);
|
||||||
|
port.Write(bytes, i, n);
|
||||||
|
}
|
||||||
|
try { port.BaseStream.Flush(); } catch { /* advisory */ }
|
||||||
|
Thread.Sleep(500);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
port.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"Sent {bytes.Length} bytes in {sw.ElapsedMilliseconds} ms. Port closed.");
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
internal static class NativeMethods
|
||||||
|
{
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CreateFileW")]
|
||||||
|
internal static extern SafeFileHandle CreateFileW(
|
||||||
|
string lpFileName,
|
||||||
|
uint dwDesiredAccess,
|
||||||
|
uint dwShareMode,
|
||||||
|
IntPtr lpSecurityAttributes,
|
||||||
|
uint dwCreationDisposition,
|
||||||
|
uint dwFlagsAndAttributes,
|
||||||
|
IntPtr hTemplateFile);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>OpenNest.Tools.StreamGravographJob</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
|
||||||
|
<ProjectReference Include="..\..\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
|
||||||
|
<ProjectReference Include="..\..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user